feat(connection-package): 支持连接恢复包双模式加密导入导出

- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路
- 扩展前后端导入导出流程并兼容 v1 与 legacy 格式
- 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
This commit is contained in:
tianqijiuyun-latiao
2026-04-11 23:51:43 +08:00
parent 1751e14d20
commit 52d2ee7592
16 changed files with 1447 additions and 49 deletions

View File

@@ -26,6 +26,7 @@ import { getConnectionWorkbenchState } from './utils/startupReadiness';
import { toSaveGlobalProxyInput } from './utils/globalProxyDraft';
import {
detectConnectionImportKind,
isConnectionPackagePasswordRequiredError,
resolveConnectionPackageExportResult,
normalizeConnectionPackagePassword,
} from './utils/connectionExport';
@@ -120,6 +121,8 @@ type ConnectionPackageDialogMode = 'import' | 'export';
type ConnectionPackageDialogState = {
open: boolean;
mode: ConnectionPackageDialogMode;
includeSecrets: boolean;
useFilePassword: boolean;
password: string;
error: string;
confirmLoading: boolean;
@@ -128,6 +131,8 @@ type ConnectionPackageDialogState = {
const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogState => ({
open: false,
mode: 'export',
includeSecrets: true,
useFilePassword: false,
password: '',
error: '',
confirmLoading: false,
@@ -1476,22 +1481,24 @@ function App() {
return;
}
if (importKind === 'encrypted-package') {
setPendingConnectionImportPayload(raw);
setConnectionPackageDialog({
open: true,
mode: 'import',
password: '',
error: '',
confirmLoading: false,
});
return;
}
try {
setPendingConnectionImportPayload(null);
const importedViews = await importConnectionsPayload(raw, '');
void message.success(`成功导入 ${importedViews.length} 个连接`);
} catch (e: any) {
if (isConnectionPackagePasswordRequiredError(e)) {
setPendingConnectionImportPayload(raw);
setConnectionPackageDialog({
open: true,
mode: 'import',
includeSecrets: true,
useFilePassword: false,
password: '',
error: '',
confirmLoading: false,
});
return;
}
void message.error(e?.message || '导入失败');
}
};
@@ -1505,6 +1512,8 @@ function App() {
setConnectionPackageDialog({
open: true,
mode: 'export',
includeSecrets: true,
useFilePassword: false,
password: '',
error: '',
confirmLoading: false,
@@ -1515,7 +1524,7 @@ function App() {
const backendApp = (window as any).go?.app?.App;
const password = normalizeConnectionPackagePassword(connectionPackageDialog.password);
if (!password) {
if (connectionPackageDialog.mode === 'import' && !password) {
setConnectionPackageDialog((current) => ({
...current,
error: '恢复包密码不能为空',
@@ -1523,9 +1532,25 @@ function App() {
return;
}
if (
connectionPackageDialog.mode === 'export'
&& connectionPackageDialog.includeSecrets
&& connectionPackageDialog.useFilePassword
&& !password
) {
setConnectionPackageDialog((current) => ({
...current,
error: '文件保护密码不能为空',
}));
return;
}
setConnectionPackageDialog((current) => ({
...current,
password,
password: (
current.mode === 'export'
&& (!current.includeSecrets || !current.useFilePassword)
) ? '' : password,
error: '',
confirmLoading: true,
}));
@@ -1536,7 +1561,13 @@ function App() {
throw new Error('导出失败:当前后端未提供新版导出能力');
}
const res = await backendApp.ExportConnectionsPackage(password);
const res = await backendApp.ExportConnectionsPackage({
includeSecrets: connectionPackageDialog.includeSecrets,
filePassword: (
connectionPackageDialog.includeSecrets
&& connectionPackageDialog.useFilePassword
) ? password : '',
});
const exportResult = resolveConnectionPackageExportResult(connectionPackageDialog, res);
if (exportResult.kind === 'canceled') {
setConnectionPackageDialog(exportResult.nextDialog);
@@ -2559,11 +2590,31 @@ function App() {
/>
<ConnectionPackagePasswordModal
open={connectionPackageDialog.open}
title={connectionPackageDialog.mode === 'export' ? '输入导出密码' : '输入导入密码'}
title={connectionPackageDialog.mode === 'export' ? '导出连接' : '输入导入密码'}
mode={connectionPackageDialog.mode}
includeSecrets={connectionPackageDialog.includeSecrets}
useFilePassword={connectionPackageDialog.useFilePassword}
password={connectionPackageDialog.password}
error={connectionPackageDialog.error}
confirmLoading={connectionPackageDialog.confirmLoading}
confirmText={connectionPackageDialog.mode === 'export' ? '开始导出' : '开始导入'}
onIncludeSecretsChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
includeSecrets: value,
useFilePassword: value ? current.useFilePassword : false,
password: value ? current.password : '',
error: '',
}));
}}
onUseFilePasswordChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
useFilePassword: value,
password: value ? current.password : '',
error: '',
}));
}}
onPasswordChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,

View File

@@ -1,16 +1,23 @@
import React from 'react';
import { Input, Modal, Typography } from 'antd';
import { Checkbox, Input, Modal, Typography } from 'antd';
const { Text } = Typography;
type ConnectionPackagePasswordModalMode = 'import' | 'export';
export interface ConnectionPackagePasswordModalProps {
open: boolean;
title: string;
mode?: ConnectionPackagePasswordModalMode;
includeSecrets?: boolean;
useFilePassword?: boolean;
password: string;
error?: string;
confirmLoading?: boolean;
confirmText?: string;
cancelText?: string;
onIncludeSecretsChange?: (value: boolean) => void;
onUseFilePasswordChange?: (value: boolean) => void;
onPasswordChange: (value: string) => void;
onConfirm: () => void;
onCancel: () => void;
@@ -19,15 +26,29 @@ export interface ConnectionPackagePasswordModalProps {
export default function ConnectionPackagePasswordModal({
open,
title,
mode = 'import',
includeSecrets = true,
useFilePassword = false,
password,
error,
confirmLoading,
confirmText = '确认',
cancelText = '取消',
onIncludeSecretsChange,
onUseFilePasswordChange,
onPasswordChange,
onConfirm,
onCancel,
}: ConnectionPackagePasswordModalProps) {
const isExportMode = mode === 'export';
const showFilePasswordInput = isExportMode ? useFilePassword : true;
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
const helperText = !includeSecrets
? '将仅导出连接配置,不包含密码。'
: (useFilePassword
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
return (
<Modal
open={open}
@@ -40,12 +61,37 @@ export default function ConnectionPackagePasswordModal({
destroyOnClose={false}
maskClosable={false}
>
<Input.Password
autoFocus
value={password}
placeholder="请输入恢复包密码"
onChange={(event) => onPasswordChange(event.target.value)}
/>
{isExportMode ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Checkbox
checked={includeSecrets}
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
>
</Checkbox>
<Checkbox
checked={useFilePassword}
disabled={!includeSecrets}
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
>
</Checkbox>
</div>
) : null}
{showFilePasswordInput ? (
<Input.Password
autoFocus
value={password}
placeholder={placeholder}
disabled={isExportMode && !useFilePassword}
onChange={(event) => onPasswordChange(event.target.value)}
/>
) : null}
{isExportMode ? (
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
{helperText}
</Text>
) : null}
{error ? (
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
{error}

View File

@@ -51,8 +51,8 @@ const importMain = async () => {
app?: {
App?: {
ImportConfigFile: () => Promise<{ success: boolean; message?: string }>;
ImportConnectionsPayload: (raw: string) => Promise<unknown>;
ExportConnectionsPackage: () => Promise<{ success: boolean; message?: string }>;
ImportConnectionsPayload: (raw: string, password?: string) => Promise<unknown>;
ExportConnectionsPackage: (options?: { includeSecrets?: boolean; filePassword?: string }) => Promise<{ success: boolean; message?: string }>;
};
};
};
@@ -83,7 +83,7 @@ describe('main browser mock', () => {
success: false,
message: '已取消',
});
await expect(app!.ExportConnectionsPackage()).resolves.toEqual({
await expect(app!.ExportConnectionsPackage({ includeSecrets: true, filePassword: '' })).resolves.toEqual({
success: false,
message: '浏览器 mock 不支持恢复包导出',
});

View File

@@ -123,7 +123,7 @@ if (typeof window !== 'undefined' && !(window as any).go) {
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
ImportConnectionsPayload: async (raw: string) => {
ImportConnectionsPayload: async (raw: string, _password?: string) => {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
@@ -134,7 +134,7 @@ if (typeof window !== 'undefined' && !(window as any).go) {
}
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
},
ExportConnectionsPackage: async () => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }),
ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }),
ExportData: async () => ({ success: false }),
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),

View File

@@ -2,13 +2,64 @@ import { describe, expect, it } from 'vitest';
import {
detectConnectionImportKind,
isConnectionPackagePasswordRequiredError,
isConnectionPackageExportCanceled,
resolveConnectionPackageExportResult,
normalizeConnectionPackagePassword,
} from './connectionExport';
describe('connectionExport', () => {
it('detects encrypted packages by gonavi envelope kind', () => {
it('detects v2 app-managed packages', () => {
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 1,
exportedAt: '2026-04-11T21:00:00Z',
connections: [],
}))).toBe('app-managed-package');
});
it('detects v2 encrypted packages', () => {
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 2,
kdf: {
n: 'a2id',
m: 65536,
t: 3,
l: 4,
s: 'c2FsdA==',
},
nc: 'bm9uY2Utbm9uY2U=',
d: 'encrypted-data',
}))).toBe('encrypted-package');
});
it('rejects malformed v2 app-managed packages without connections array', () => {
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 1,
exportedAt: '2026-04-11T21:00:00Z',
}))).toBe('invalid');
});
it('rejects malformed v2 encrypted packages without protected payload fields', () => {
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 2,
kdf: {
n: 'a2id',
m: 65536,
t: 3,
l: 4,
},
}))).toBe('invalid');
});
it('detects v1 encrypted packages by gonavi envelope kind', () => {
expect(detectConnectionImportKind(JSON.stringify({
schemaVersion: 1,
kind: 'gonavi_connection_package',
@@ -39,6 +90,15 @@ describe('connectionExport', () => {
it('returns invalid for malformed or unsupported content', () => {
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 0,
}))).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
}))).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
kind: 'gonavi_connection_package',
payload: 'encrypted-data',
@@ -60,6 +120,14 @@ describe('connectionExport', () => {
expect(normalizeConnectionPackagePassword('\n\t \t')).toBe('');
});
it('recognizes backend password-required errors for protected packages', () => {
expect(isConnectionPackagePasswordRequiredError(new Error('恢复包密码不能为空'))).toBe(true);
expect(isConnectionPackagePasswordRequiredError({ message: '恢复包密码不能为空' })).toBe(true);
expect(isConnectionPackagePasswordRequiredError('恢复包密码不能为空')).toBe(true);
expect(isConnectionPackagePasswordRequiredError(new Error('文件密码错误或文件已损坏'))).toBe(false);
expect(isConnectionPackagePasswordRequiredError(undefined)).toBe(false);
});
it('treats export cancel as a non-error backend result', () => {
expect(isConnectionPackageExportCanceled({ success: false, message: '已取消' })).toBe(true);
expect(isConnectionPackageExportCanceled({ success: false, message: '导出失败' })).toBe(false);
@@ -71,6 +139,8 @@ describe('connectionExport', () => {
const staleDialog = {
open: true,
mode: 'export' as const,
includeSecrets: true,
useFilePassword: false,
password: ' secret-pass ',
error: '上一次失败',
confirmLoading: false,
@@ -83,12 +153,16 @@ describe('connectionExport', () => {
expect((canceledResult.nextDialog as (current: typeof staleDialog) => typeof staleDialog)({
open: false,
mode: 'export',
includeSecrets: true,
useFilePassword: false,
password: 'secret-pass',
error: '更新后的错误',
confirmLoading: true,
})).toEqual({
open: false,
mode: 'export',
includeSecrets: true,
useFilePassword: false,
password: 'secret-pass',
error: '',
confirmLoading: false,

View File

@@ -1,9 +1,11 @@
import type { ConnectionConfig, SavedConnection } from '../types';
export type ConnectionImportKind = 'encrypted-package' | 'legacy-json' | 'invalid';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'invalid';
export type ConnectionPackageDialogSnapshot = {
open: boolean;
mode: 'export' | 'import';
includeSecrets: boolean;
useFilePassword: boolean;
password: string;
error: string;
confirmLoading: boolean;
@@ -20,7 +22,11 @@ export type ConnectionPackageExportResult =
type JsonObject = Record<string, unknown>;
const CONNECTION_PACKAGE_KIND = 'gonavi_connection_package';
const CONNECTION_PACKAGE_SCHEMA_VERSION_V2 = 2;
const CONNECTION_PACKAGE_PROTECTION_APP_MANAGED = 1;
const CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD = 2;
const CANCELED_MESSAGE = '已取消';
const CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE = '恢复包密码不能为空';
const isJsonObject = (value: unknown): value is JsonObject => (
typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -45,6 +51,36 @@ const isConnectionPackageEnvelope = (value: unknown): value is JsonObject => (
&& typeof value.payload === 'string'
);
const isConnectionPackageV2Envelope = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& value.kind === CONNECTION_PACKAGE_KIND
&& value.v === CONNECTION_PACKAGE_SCHEMA_VERSION_V2
&& typeof value.p === 'number'
);
const isConnectionPackageKDFV2 = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.n === 'string'
&& typeof value.m === 'number'
&& typeof value.t === 'number'
&& typeof value.l === 'number'
&& typeof value.s === 'string'
);
const isConnectionPackageV2AppManagedEnvelope = (value: unknown): value is JsonObject => (
isConnectionPackageV2Envelope(value)
&& value.p === CONNECTION_PACKAGE_PROTECTION_APP_MANAGED
&& Array.isArray(value.connections)
);
const isConnectionPackageV2ProtectedEnvelope = (value: unknown): value is JsonObject => (
isConnectionPackageV2Envelope(value)
&& value.p === CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD
&& isConnectionPackageKDFV2(value.kdf)
&& typeof value.nc === 'string'
&& typeof value.d === 'string'
);
const isLegacyConnectionConfig = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.type === 'string'
@@ -72,6 +108,18 @@ const parseConnectionImportRaw = (raw: unknown): unknown => {
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
const parsed = parseConnectionImportRaw(raw);
if (isConnectionPackageV2AppManagedEnvelope(parsed)) {
return 'app-managed-package';
}
if (isConnectionPackageV2ProtectedEnvelope(parsed)) {
return 'encrypted-package';
}
if (isConnectionPackageV2Envelope(parsed)) {
return 'invalid';
}
if (Array.isArray(parsed) && parsed.every((item) => isLegacyConnectionItem(item))) {
return 'legacy-json';
}
@@ -85,6 +133,20 @@ export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind =
export const normalizeConnectionPackagePassword = (value: string): string => value.trim();
export const isConnectionPackagePasswordRequiredError = (value: unknown): boolean => {
if (typeof value === 'string') {
return value.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
}
if (value instanceof Error) {
return value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
}
return isJsonObject(value)
&& typeof value.message === 'string'
&& value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
};
export const isConnectionPackageExportCanceled = (result: unknown): boolean => (
isJsonObject(result)
&& result.success === false