mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 00:13:33 +08:00
🐛 fix(web): 补齐浏览器端连接导入导出
This commit is contained in:
@@ -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 文件包含明文密码,可被有容器检查权限的用户看到,请限制文件读取权限。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
38
frontend/src/utils/browserFileTransfer.test.ts
Normal file
38
frontend/src/utils/browserFileTransfer.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
31
frontend/src/utils/browserFileTransfer.ts
Normal file
31
frontend/src/utils/browserFileTransfer.ts
Normal 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;
|
||||
};
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user