diff --git a/README.zh-CN.md b/README.zh-CN.md index 9b934cbc..fd486985 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 文件包含明文密码,可被有容器检查权限的用户看到,请限制文件读取权限。 diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 59b36219..db9feed6 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -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( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a9c8ff99..0bd66ea3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(EMPTY_INSTALLED_FONT_FAMILIES); const [isFontFamiliesLoading, setIsFontFamiliesLoading] = useState(false); const [fontFamiliesLoadError, setFontFamiliesLoadError] = useState(null); @@ -867,6 +869,8 @@ function App() { const [focusedAIProviderId, setFocusedAIProviderId] = useState(undefined); const [connectionPackageDialog, setConnectionPackageDialog] = useState(() => createClosedConnectionPackageDialogState()); const [pendingConnectionImportPayload, setPendingConnectionImportPayload] = useState(null); + const browserConnectionImportInputRef = useRef(null); + const browserConnectionImportSourceGroupRef = useRef(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) => { + 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, }}> + { void handleBrowserConnectionImportFileChange(event); }} + /> {/* Custom Title Bar */}
({ ...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 ( +
+
+
{t('app.data_root.current_directory')}
+
+ +
+
+
{t('app.data_root.default_directory')}
+
{dataRootInfo?.defaultPath || '-'}
+
+
+
{t('app.data_root.driver_directory')}
+
{dataRootInfo?.driverPath || '-'}
+
+
+
+
+
+ ); + } return ( { + 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']); + }); +}); diff --git a/frontend/src/utils/browserFileTransfer.ts b/frontend/src/utils/browserFileTransfer.ts new file mode 100644 index 00000000..e3544dbe --- /dev/null +++ b/frontend/src/utils/browserFileTransfer.ts @@ -0,0 +1,31 @@ +type BrowserFileDownloadEnvironment = { + document?: Pick; + url?: Pick; +}; + +/** 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; +}; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 4c26f4e8..8361ceac 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -117,6 +117,8 @@ export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3 export function ExportConnectionsPackage(arg1:app.ConnectionExportOptions):Promise; +export function ExportConnectionsPayload(arg1:app.ConnectionExportOptions):Promise; + export function ExportData(arg1:Array>,arg2:Array,arg3:string,arg4:string):Promise; export function ExportDataWithOptions(arg1:Array>,arg2:Array,arg3:string,arg4:app.ExportFileOptions):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 6a558675..507c78fb 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -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); } diff --git a/internal/app/connection_package_transfer_test.go b/internal/app/connection_package_transfer_test.go index 44f48ae4..85197866 100644 --- a/internal/app/connection_package_transfer_test.go +++ b/internal/app/connection_package_transfer_test.go @@ -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() diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 2ea731db..ffd7b8bf 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -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 == "" {