🐛 fix(web): 补齐浏览器端连接导入导出

This commit is contained in:
Syngnat
2026-07-10 20:57:44 +08:00
parent 689467ec91
commit 69be54c1cf
9 changed files with 230 additions and 28 deletions

View File

@@ -218,6 +218,8 @@ docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml
浏览器打开 `http://127.0.0.1:34116`(或宿主机映射端口)。设置 `GONAVI_WEB_PASSWORD` 后,新创建的容器会同步该密码并直接进入登录页;未设置时首次访问完成 `/setup`
Web 工具中心支持通过浏览器上传/下载连接恢复包数据目录页显示当前容器挂载目录。Docker 的数据根目录由 `GONAVI_HOST_DATA_ROOT` 挂载控制,如需切换请修改 env 后重新创建容器,避免在容器内迁移到未持久化路径。
请把 GoNavi 活动数据目录挂载为 `/data`。建议至少包含 `connections.json``daily_secrets.json`;如需可选驱动代理应包含 `drivers/`。Web 认证状态写入同目录下的 `web_auth.json`
`GONAVI_WEB_PASSWORD` 会覆盖 `web_auth.json` 中已有的密码哈希,但保留 2FA 和会话策略移除环境变量后继续使用最后一次同步的密码。env 文件包含明文密码,可被有容器检查权限的用户看到,请限制文件读取权限。

View File

@@ -111,6 +111,15 @@ describe('tool center menu entries', () => {
expect(appSource).toContain("borderBottom: `1px solid ${overlayTheme.divider}`");
});
it('keeps browser-compatible connection transfer and mounted data-root entries available in the web runtime', () => {
expect(appSource).toContain("accept=\".gonavi-conn,.json,.xml,.ncx\"");
expect(appSource).toContain('ExportConnectionsPayload');
expect(appSource).toContain('downloadBrowserTextFile');
expect(appSource).toContain("__GONAVI_WEB_RUNTIME__?.buildType === 'web'");
expect(appSource).toContain('if (isWebRuntime) {\n return (');
expect(appSource).toContain('items: group.items,');
});
it('lets the tool center detail header own embedded tool titles', () => {
const renderPaneStart = appSource.indexOf('const renderToolCenterPane = () => {');
const renderPaneSource = appSource.slice(

View File

@@ -65,6 +65,7 @@ import {
resolveConnectionPackageExportResult,
normalizeConnectionPackagePassword,
} from './utils/connectionExport';
import { downloadBrowserTextFile } from './utils/browserFileTransfer';
import {
mergeRedisDbAliases,
sanitizeRedisDbAliases,
@@ -835,7 +836,8 @@ function App() {
const [runtimePlatform, setRuntimePlatform] = useState('');
const [runtimeBuildType, setRuntimeBuildType] = useState('');
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const isWebRuntime = runtimeBuildType === 'web';
const isWebRuntime = runtimeBuildType === 'web'
|| (typeof window !== 'undefined' && (window as any).__GONAVI_WEB_RUNTIME__?.buildType === 'web');
const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);
const [isFontFamiliesLoading, setIsFontFamiliesLoading] = useState(false);
const [fontFamiliesLoadError, setFontFamiliesLoadError] = useState<string | null>(null);
@@ -867,6 +869,8 @@ function App() {
const [focusedAIProviderId, setFocusedAIProviderId] = useState<string | undefined>(undefined);
const [connectionPackageDialog, setConnectionPackageDialog] = useState<ConnectionPackageDialogState>(() => createClosedConnectionPackageDialogState());
const [pendingConnectionImportPayload, setPendingConnectionImportPayload] = useState<string | null>(null);
const browserConnectionImportInputRef = useRef<HTMLInputElement>(null);
const browserConnectionImportSourceGroupRef = useRef<ToolCenterGroupKey | undefined>(undefined);
const [aiPanelRenderNonce, setAiPanelRenderNonce] = useState(0);
const sidebarWidth = useStore(state => state.sidebarWidth);
const setSidebarWidth = useStore(state => state.setSidebarWidth);
@@ -2493,17 +2497,7 @@ function App() {
return imported.connections;
}, [refreshConnectionsAfterImport, t]);
const handleImportConnections = async (sourceGroup?: ToolCenterGroupKey) => {
setToolCenterBackGroupKey(sourceGroup ?? null);
const res = await (window as any).go.app.App.ImportConfigFile();
if (!res.success) {
if (res.message !== "已取消") {
void message.error(t('app.connection_package.message.import_failed_with_error', { error: res.message }));
}
return;
}
const raw = typeof res.data === 'string' ? res.data : String(res.data ?? '');
const importConnectionPayloadFromFile = async (raw: string, sourceGroup?: ToolCenterGroupKey) => {
const importKind = detectConnectionImportKind(raw);
if (importKind === 'invalid') {
@@ -2541,6 +2535,49 @@ function App() {
}
};
const handleBrowserConnectionImportFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const sourceGroup = browserConnectionImportSourceGroupRef.current;
browserConnectionImportSourceGroupRef.current = undefined;
event.target.value = '';
if (!file) {
return;
}
try {
await importConnectionPayloadFromFile(await file.text(), sourceGroup);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error ?? '').trim();
void message.error(detail || t('app.connection_package.message.import_failed'));
}
};
const handleImportConnections = async (sourceGroup?: ToolCenterGroupKey) => {
setToolCenterBackGroupKey(sourceGroup ?? null);
if (isWebRuntime) {
const input = browserConnectionImportInputRef.current;
if (!input) {
void message.error(t('app.connection_package.error.import_capability_unavailable'));
return;
}
browserConnectionImportSourceGroupRef.current = sourceGroup;
input.value = '';
input.click();
return;
}
const res = await (window as any).go.app.App.ImportConfigFile();
if (!res.success) {
if (res.message !== "已取消") {
void message.error(t('app.connection_package.message.import_failed_with_error', { error: res.message }));
}
return;
}
const raw = typeof res.data === 'string' ? res.data : String(res.data ?? '');
await importConnectionPayloadFromFile(raw, sourceGroup);
};
const handleExportConnections = async (sourceGroup?: ToolCenterGroupKey) => {
if (connections.length === 0) {
void message.warning(t('app.connection_package.message.no_connections_to_export'));
@@ -2599,13 +2636,16 @@ function App() {
try {
if (connectionPackageDialog.mode === 'export') {
if (typeof backendApp?.ExportConnectionsPackage !== 'function') {
throw new Error(t('app.connection_package.error.export_capability_unavailable'));
}
const exportMethod = isWebRuntime
? backendApp?.ExportConnectionsPayload
: backendApp?.ExportConnectionsPackage;
if (typeof exportMethod !== 'function') {
throw new Error(t('app.connection_package.error.export_capability_unavailable'));
}
let res: unknown;
try {
res = await backendApp.ExportConnectionsPackage({
let res: unknown;
try {
res = await exportMethod({
includeSecrets: connectionPackageDialog.includeSecrets,
filePassword: (
connectionPackageDialog.includeSecrets
@@ -2627,11 +2667,17 @@ function App() {
setConnectionPackageDialog(exportResult.nextDialog);
return;
}
if (exportResult.kind === 'failed') {
throw new Error(exportResult.error);
}
if (exportResult.kind === 'failed') {
throw new Error(exportResult.error);
}
if (isWebRuntime) {
const content = typeof (res as any)?.data === 'string' ? (res as any).data : '';
if (!content || !downloadBrowserTextFile(content, 'connections.gonavi-conn', 'application/json;charset=utf-8')) {
throw new Error(t('app.connection_package.error.export_capability_unavailable'));
}
}
closeConnectionPackageDialog();
closeConnectionPackageDialog();
void message.success(t('app.connection_package.message.export_succeeded'));
return;
}
@@ -6427,6 +6473,13 @@ function App() {
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
}}>
<input
ref={browserConnectionImportInputRef}
type="file"
accept=".gonavi-conn,.json,.xml,.ncx"
style={{ display: 'none' }}
onChange={(event) => { void handleBrowserConnectionImportFileChange(event); }}
/>
{/* Custom Title Bar */}
<div
onDoubleClick={handleTitleBarDoubleClick}
@@ -6960,12 +7013,7 @@ function App() {
] as const;
const filteredToolCenterGroups = toolCenterGroups.map((group) => ({
...group,
items: group.items.filter((item) => {
if (!isWebRuntime) {
return true;
}
return !['import', 'export', 'data-root'].includes(String(item.key || ''));
}),
items: group.items,
})).filter((group) => group.items.length > 0);
const activeToolCenterGroup = filteredToolCenterGroups.find((group) => group.key === activeToolCenterGroupKey) ?? filteredToolCenterGroups[0];
const activeToolCenterPaneItem = activeToolCenterPane
@@ -7041,6 +7089,28 @@ function App() {
}
if (activeToolCenterPane.key === 'data-root') {
if (isWebRuntime) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}>{t('app.data_root.current_directory')}</div>
<div style={{ display: 'grid', gap: 10 }}>
<Input readOnly value={dataRootInfo?.path || ''} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}>{t('app.data_root.default_directory')}</div>
<div style={utilityMutedTextStyle}>{dataRootInfo?.defaultPath || '-'}</div>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}>{t('app.data_root.driver_directory')}</div>
<div style={utilityMutedTextStyle}>{dataRootInfo?.driverPath || '-'}</div>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<Modal
embedded

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { downloadBrowserTextFile } from './browserFileTransfer';
describe('downloadBrowserTextFile', () => {
it('creates a browser download and releases its object URL', () => {
const created: Array<{ href: string; download: string; clicked: boolean }> = [];
const removed: unknown[] = [];
const revoked: string[] = [];
const body = {
appendChild: (node: unknown) => { created.push(node as { href: string; download: string; clicked: boolean }); },
removeChild: (node: unknown) => { removed.push(node); },
};
const documentLike = {
body,
createElement: () => ({
href: '',
download: '',
clicked: false,
click() { this.clicked = true; },
}),
} as unknown as Document;
const urlLike = {
createObjectURL: () => 'blob:connection-package',
revokeObjectURL: (value: string) => { revoked.push(value); },
} as unknown as typeof URL;
const downloaded = downloadBrowserTextFile('package-content', 'connections.gonavi-conn', 'application/json', {
document: documentLike,
url: urlLike,
});
expect(downloaded).toBe(true);
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({ href: 'blob:connection-package', download: 'connections.gonavi-conn', clicked: true });
expect(removed).toHaveLength(1);
expect(revoked).toEqual(['blob:connection-package']);
});
});

View File

@@ -0,0 +1,31 @@
type BrowserFileDownloadEnvironment = {
document?: Pick<Document, 'body' | 'createElement'>;
url?: Pick<typeof URL, 'createObjectURL' | 'revokeObjectURL'>;
};
/** Trigger a local browser download without asking the server to write a client-side path. */
export const downloadBrowserTextFile = (
content: string,
filename: string,
contentType: string,
environment: BrowserFileDownloadEnvironment = {},
): boolean => {
const documentRef = environment.document ?? (typeof document === 'undefined' ? undefined : document);
const urlRef = environment.url ?? (typeof URL === 'undefined' ? undefined : URL);
if (!documentRef?.body || !urlRef?.createObjectURL || !urlRef.revokeObjectURL) {
return false;
}
const url = urlRef.createObjectURL(new Blob([content], { type: contentType }));
const anchor = documentRef.createElement('a');
anchor.href = url;
anchor.download = filename;
documentRef.body.appendChild(anchor);
try {
anchor.click();
} finally {
documentRef.body.removeChild(anchor);
urlRef.revokeObjectURL(url);
}
return true;
};

View File

@@ -117,6 +117,8 @@ export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3
export function ExportConnectionsPackage(arg1:app.ConnectionExportOptions):Promise<connection.QueryResult>;
export function ExportConnectionsPayload(arg1:app.ConnectionExportOptions):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDataWithOptions(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:app.ExportFileOptions):Promise<connection.QueryResult>;

View File

@@ -222,6 +222,10 @@ export function ExportConnectionsPackage(arg1) {
return window['go']['app']['App']['ExportConnectionsPackage'](arg1);
}
export function ExportConnectionsPayload(arg1) {
return window['go']['app']['App']['ExportConnectionsPayload'](arg1);
}
export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}

View File

@@ -149,6 +149,35 @@ func TestBuildExportedConnectionPackageWithoutSecretsUsesV2AppManagedAndImportsW
}
}
func TestExportConnectionsPayloadReturnsBrowserDownloadContent(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
saveConnectionForPackageExport(t, app, "conn-browser-export", "browser-secret")
result := app.ExportConnectionsPayload(ConnectionExportOptions{IncludeSecrets: false})
if !result.Success {
t.Fatalf("ExportConnectionsPayload returned failure: %+v", result)
}
raw, ok := result.Data.(string)
if !ok || strings.TrimSpace(raw) == "" {
t.Fatalf("expected browser download content, got %#v", result.Data)
}
if !isConnectionPackageV2AppManaged(raw) {
t.Fatalf("expected app-managed connection package, got %s", raw)
}
importApp := NewAppWithSecretStore(newFakeAppSecretStore())
importApp.configDir = t.TempDir()
imported, err := importApp.ImportConnectionsPayload(raw, "")
if err != nil {
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
}
if len(imported.Connections) != 1 || imported.Connections[0].ID != "conn-browser-export" {
t.Fatalf("expected exported browser content to restore the connection, got %#v", imported.Connections)
}
}
func TestImportConnectionPackagePayloadOverwritesExistingSecrets(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()

View File

@@ -1800,6 +1800,23 @@ func (a *App) ExportConnectionsPackage(options ConnectionExportOptions) connecti
return connection.QueryResult{Success: true, Message: a.appText("file.backend.message.export_completed", nil)}
}
// ExportConnectionsPayload builds a recovery package for browser clients. The browser is
// responsible for saving it locally because a Web Server process cannot open its file dialog.
func (a *App) ExportConnectionsPayload(options ConnectionExportOptions) connection.QueryResult {
content, err := a.buildExportedConnectionPackage(options)
if err != nil {
return connection.QueryResult{Success: false, Message: localizedConnectionPackageExportMessage(a.appText, err)}
}
if len(content) > connectionImportMaxFileBytes {
return connection.QueryResult{Success: false, Message: localizedConnectionPackageExportMessage(a.appText, errConnectionImportFileTooLarge)}
}
return connection.QueryResult{
Success: true,
Message: a.appText("file.backend.message.export_completed", nil),
Data: string(content),
}
}
func normalizeConnectionPackageExportFilename(filename string) string {
trimmed := strings.TrimSpace(filename)
if trimmed == "" {