Skip to content

Commit 4bc69c6

Browse files
committed
fix: make gateway headers structured in settings
1 parent 93db8ef commit 4bc69c6

12 files changed

Lines changed: 412 additions & 61 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { Plus, Trash2 } from 'lucide-react';
3+
4+
import {
5+
formatGatewayHeaderEntries,
6+
parseGatewayHeaderEntries,
7+
type GatewayHeaderEntry,
8+
} from '@shared/gateway-headers';
9+
10+
import { Button } from '@/components/ui/button';
11+
import { Input } from '@/components/ui/input';
12+
import { useI18n } from './i18n';
13+
14+
type GatewayHeaderRow = GatewayHeaderEntry & {
15+
id: string;
16+
};
17+
18+
type GatewayHeadersEditorProps = {
19+
value: string;
20+
onChange: (value: string) => void;
21+
className?: string;
22+
};
23+
24+
let nextHeaderRowId = 0;
25+
26+
function createHeaderRow(entry?: Partial<GatewayHeaderEntry>): GatewayHeaderRow {
27+
nextHeaderRowId += 1;
28+
return {
29+
id: `gateway-header-${nextHeaderRowId}`,
30+
name: entry?.name ?? '',
31+
value: entry?.value ?? '',
32+
};
33+
}
34+
35+
function rowsFromValue(value: string): GatewayHeaderRow[] {
36+
const rows = parseGatewayHeaderEntries(value).map((entry) => createHeaderRow(entry));
37+
return rows.length > 0 ? rows : [createHeaderRow()];
38+
}
39+
40+
function rowsToValue(rows: readonly GatewayHeaderRow[]): string {
41+
return formatGatewayHeaderEntries(rows);
42+
}
43+
44+
export function GatewayHeadersEditor({
45+
value,
46+
onChange,
47+
className,
48+
}: GatewayHeadersEditorProps) {
49+
const { t } = useI18n();
50+
const lastValueRef = useRef(value);
51+
const [rows, setRows] = useState<GatewayHeaderRow[]>(() => rowsFromValue(value));
52+
53+
useEffect(() => {
54+
if (value === lastValueRef.current) {
55+
return;
56+
}
57+
lastValueRef.current = value;
58+
setRows(rowsFromValue(value));
59+
}, [value]);
60+
61+
function emit(nextRows: GatewayHeaderRow[]) {
62+
setRows(nextRows);
63+
const nextValue = rowsToValue(nextRows);
64+
lastValueRef.current = nextValue;
65+
onChange(nextValue);
66+
}
67+
68+
function updateRow(rowId: string, field: 'name' | 'value', nextValue: string) {
69+
emit(rows.map((row) => (row.id === rowId ? { ...row, [field]: nextValue } : row)));
70+
}
71+
72+
function removeRow(rowId: string) {
73+
const nextRows = rows.filter((row) => row.id !== rowId);
74+
emit(nextRows.length > 0 ? nextRows : [createHeaderRow()]);
75+
}
76+
77+
function addRow() {
78+
setRows((current) => [...current, createHeaderRow()]);
79+
}
80+
81+
return (
82+
<div className={['gateway-headers-editor', className].filter(Boolean).join(' ')}>
83+
<div className="gateway-headers-editor-list">
84+
{rows.map((row) => (
85+
<div className="gateway-headers-editor-row" key={row.id}>
86+
<Input
87+
autoCapitalize="off"
88+
autoComplete="off"
89+
aria-label={t('Header name')}
90+
className="gateway-headers-editor-input"
91+
placeholder={t('Header name')}
92+
spellCheck={false}
93+
type="text"
94+
value={row.name}
95+
onChange={(event) => updateRow(row.id, 'name', event.target.value)}
96+
/>
97+
<Input
98+
autoCapitalize="off"
99+
autoComplete="off"
100+
aria-label={t('Header value')}
101+
className="gateway-headers-editor-input"
102+
placeholder={t('Header value')}
103+
spellCheck={false}
104+
type="text"
105+
value={row.value}
106+
onChange={(event) => updateRow(row.id, 'value', event.target.value)}
107+
/>
108+
<Button
109+
aria-label={t('Remove header')}
110+
className="gateway-headers-editor-remove"
111+
disabled={rows.length === 1 && !row.name.trim() && !row.value.trim()}
112+
onClick={() => removeRow(row.id)}
113+
size="icon-sm"
114+
type="button"
115+
variant="ghost"
116+
>
117+
<Trash2 aria-hidden />
118+
</Button>
119+
</div>
120+
))}
121+
</div>
122+
<Button
123+
className="gateway-headers-editor-add"
124+
onClick={addRow}
125+
size="sm"
126+
type="button"
127+
variant="outline"
128+
>
129+
<Plus aria-hidden />
130+
{t('Add header')}
131+
</Button>
132+
</div>
133+
);
134+
}

desktop/garyx-desktop/src/renderer/src/GatewaySettingsPanel.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
7676
import { buildAgentTargetOptions, type AgentTargetOption } from './app-shell/agent-options';
7777
import { AddBotDialog } from './app-shell/components/AddBotDialog';
7878
import { AgentOptionAvatar } from './app-shell/components/AgentOptionAvatar';
79+
import { GatewayHeadersEditor } from './GatewayHeadersEditor';
7980
import { WorkspacePathPicker } from './components/WorkspacePathPicker';
8081
import { MoreDotsIcon } from './app-shell/icons';
8182
import { ChannelPluginCatalogPanel } from './channel-plugins/ChannelPluginCatalogPanel';
@@ -1344,18 +1345,13 @@ function GatewayProfileDialog({
13441345
onChange={(event) => setGatewayAuthToken(event.target.value)}
13451346
/>
13461347
</label>
1347-
<label className="gateway-setup-field">
1348+
<div className="gateway-setup-field">
13481349
<span>{t('Headers')}</span>
1349-
<Textarea
1350-
autoCapitalize="off"
1351-
autoComplete="off"
1352-
className="gateway-profile-headers-editor"
1353-
placeholder="X-Garyx-Gateway: value"
1354-
spellCheck={false}
1350+
<GatewayHeadersEditor
13551351
value={gatewayHeaders}
1356-
onChange={(event) => setGatewayHeaders(event.target.value)}
1352+
onChange={setGatewayHeaders}
13571353
/>
1358-
</label>
1354+
</div>
13591355
</div>
13601356
<DialogFooter>
13611357
<Button

desktop/garyx-desktop/src/renderer/src/app-shell/AppShell.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,10 @@ import {
9898
} from "../message-machine";
9999
import type { SettingsTabId } from "../settings-tabs";
100100
import { GatewayProfileHistoryButton } from "../GatewayProfileHistoryButton";
101+
import { GatewayHeadersEditor } from "../GatewayHeadersEditor";
101102
import { GatewayIdentityBar } from "../GatewaySwitcher";
102103
import { SettingsErrorBoundary } from "../SettingsErrorBoundary";
103104
import { Input } from "../components/ui/input";
104-
import { Textarea } from "../components/ui/textarea";
105105
import { WorkspacePathPickerDialog } from "../components/WorkspacePathPicker";
106106
import { AddBotDialog } from "./components/AddBotDialog";
107107
import { DreamsPanel } from "./components/DreamsPanel";
@@ -9981,24 +9981,19 @@ export function AppShell() {
99819981
/>
99829982
</label>
99839983

9984-
<label className="gateway-setup-field">
9984+
<div className="gateway-setup-field">
99859985
<span>{t('Headers')}</span>
9986-
<Textarea
9987-
autoCapitalize="off"
9988-
autoComplete="off"
9989-
className="gateway-setup-textarea"
9990-
placeholder="X-Garyx-Gateway: value"
9991-
spellCheck={false}
9986+
<GatewayHeadersEditor
99929987
value={settingsDraft.gatewayHeaders}
9993-
onChange={(event) => {
9988+
onChange={(value) => {
99949989
setLocalSettingsStatus(null);
99959990
setSettingsDraft((current) => ({
99969991
...current,
9997-
gatewayHeaders: event.target.value,
9992+
gatewayHeaders: value,
99989993
}));
99999994
}}
100009995
/>
10001-
</label>
9996+
</div>
100029997
</div>
100039998

100049999
<p

desktop/garyx-desktop/src/renderer/src/i18n/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,10 @@ const zhCN: Record<string, string> = {
811811
'Global': '全局',
812812
'Global prompt shortcuts are loaded from the current Gateway config.': '全局提示词快捷指令会从当前 Gateway 配置加载。',
813813
'Headers': '标头',
814+
'Header name': '标头名称',
815+
'Header value': '标头值',
816+
'Add header': '添加标头',
817+
'Remove header': '删除标头',
814818
'{count} custom headers': '{count} 个自定义标头',
815819
'Hide Advanced': '隐藏高级',
816820
'hours': '小时',

desktop/garyx-desktop/src/renderer/src/styles.css

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -468,13 +468,48 @@ code {
468468
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.08);
469469
}
470470

471-
.gateway-setup-textarea,
472-
.gateway-profile-headers-editor {
473-
min-height: 92px;
471+
.gateway-headers-editor {
472+
display: flex;
473+
flex-direction: column;
474+
gap: 8px;
475+
width: 100%;
476+
}
477+
478+
.gateway-headers-editor-list {
479+
display: flex;
480+
flex-direction: column;
481+
gap: 6px;
482+
}
483+
484+
.gateway-headers-editor-row {
485+
display: grid;
486+
grid-template-columns: minmax(0, 1fr) 32px;
487+
gap: 6px;
488+
align-items: center;
489+
}
490+
491+
.gateway-headers-editor-row .gateway-headers-editor-input:first-child {
492+
grid-column: 1 / -1;
493+
}
494+
495+
.gateway-headers-editor-input {
496+
height: 34px;
474497
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
475498
font-size: 12px;
476499
}
477500

501+
.gateway-headers-editor-remove {
502+
color: rgba(15, 23, 42, 0.52);
503+
}
504+
505+
.gateway-headers-editor-add {
506+
align-self: flex-start;
507+
border-color: #e7e7e5;
508+
background: #fff;
509+
color: #111;
510+
box-shadow: none;
511+
}
512+
478513
.gateway-url-input-shell {
479514
position: relative;
480515
width: 100%;

desktop/garyx-desktop/src/shared/gateway-headers.ts

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
22

3+
export interface GatewayHeaderEntry {
4+
name: string;
5+
value: string;
6+
}
7+
38
export function normalizeGatewayHeadersBlock(value: unknown): string {
49
if (typeof value !== "string") {
510
return "";
@@ -12,32 +17,60 @@ export function normalizeGatewayHeadersBlock(value: unknown): string {
1217
.join("\n");
1318
}
1419

15-
export function parseGatewayHeadersBlock(value: unknown): Record<string, string> {
16-
const headers: Record<string, string> = {};
20+
function headerSeparatorIndex(line: string): number {
21+
const colonIndex = line.indexOf(":");
22+
const equalsIndex = line.indexOf("=");
23+
if (colonIndex >= 0 && (equalsIndex < 0 || colonIndex < equalsIndex)) {
24+
return colonIndex;
25+
}
26+
return equalsIndex;
27+
}
28+
29+
export function parseGatewayHeaderEntries(value: unknown): GatewayHeaderEntry[] {
1730
const block = normalizeGatewayHeadersBlock(value);
1831
if (!block) {
19-
return headers;
32+
return [];
2033
}
2134

35+
const entries: GatewayHeaderEntry[] = [];
2236
for (const line of block.split("\n")) {
2337
if (!line || line.startsWith("#")) {
2438
continue;
2539
}
26-
const colonIndex = line.indexOf(":");
27-
const equalsIndex = line.indexOf("=");
28-
const separatorIndex =
29-
colonIndex >= 0 && (equalsIndex < 0 || colonIndex < equalsIndex)
30-
? colonIndex
31-
: equalsIndex;
40+
const separatorIndex = headerSeparatorIndex(line);
3241
if (separatorIndex <= 0) {
3342
continue;
3443
}
3544
const name = line.slice(0, separatorIndex).trim();
36-
const rawValue = line.slice(separatorIndex + 1).trim();
45+
if (!name) {
46+
continue;
47+
}
48+
entries.push({
49+
name,
50+
value: line.slice(separatorIndex + 1).trim(),
51+
});
52+
}
53+
return entries;
54+
}
55+
56+
export function formatGatewayHeaderEntries(entries: readonly GatewayHeaderEntry[]): string {
57+
return entries
58+
.map((entry) => ({
59+
name: entry.name.trim(),
60+
value: entry.value.trim(),
61+
}))
62+
.filter((entry) => entry.name.length > 0)
63+
.map((entry) => `${entry.name}: ${entry.value}`)
64+
.join("\n");
65+
}
66+
67+
export function parseGatewayHeadersBlock(value: unknown): Record<string, string> {
68+
const headers: Record<string, string> = {};
69+
for (const { name, value: headerValue } of parseGatewayHeaderEntries(value)) {
3770
if (!HEADER_NAME_PATTERN.test(name)) {
3871
continue;
3972
}
40-
headers[name] = rawValue;
73+
headers[name] = headerValue;
4174
}
4275

4376
return headers;

docs/configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -867,8 +867,8 @@ gateway connection shape:
867867

868868
- `gatewayUrl`: the URL the phone can reach.
869869
- `gatewayAuthToken`: the token created by `garyx gateway token`.
870-
- `gatewayHeaders`: optional custom HTTP headers, one per line, for reverse
871-
proxies or tunnels.
870+
- `gatewayHeaders`: optional custom HTTP headers for reverse proxies or
871+
tunnels; the apps edit these as one name/value row per header.
872872

873873
For a physical phone, the gateway must be reachable from the LAN. A managed
874874
macOS gateway service is installed to listen on `0.0.0.0`; if you run the

0 commit comments

Comments
 (0)