diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 00cbaaa..c285de3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,8 @@ import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, is import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow'; import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme'; import { getConnectionWorkbenchState } from './utils/startupReadiness'; +import { createGlobalProxyDraft, toSaveGlobalProxyInput } from './utils/globalProxyDraft'; +import { LEGACY_PERSIST_KEY, readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './utils/legacyConnectionStorage'; import { SHORTCUT_ACTION_META, SHORTCUT_ACTION_ORDER, @@ -35,7 +37,7 @@ import { resolveAIEdgeHandleDockStyle, resolveAIEdgeHandleStyle, } from './utils/aiEntryLayout'; -import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; +import { SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; import './App.css'; const { Sider, Content } = Layout; @@ -76,6 +78,8 @@ function App() { const setStartupFullscreen = useStore(state => state.setStartupFullscreen); const globalProxy = useStore(state => state.globalProxy); const setGlobalProxy = useStore(state => state.setGlobalProxy); + const replaceConnections = useStore(state => state.replaceConnections); + const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy); const shortcutOptions = useStore(state => state.shortcutOptions); const updateShortcut = useStore(state => state.updateShortcut); const resetShortcutOptions = useStore(state => state.resetShortcutOptions); @@ -100,14 +104,14 @@ function App() { const [runtimePlatform, setRuntimePlatform] = useState(''); const [isLinuxRuntime, setIsLinuxRuntime] = useState(false); const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); - const [hasAppliedInitialGlobalProxy, setHasAppliedInitialGlobalProxy] = useState(false); + const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false); const sidebarWidth = useStore(state => state.sidebarWidth); const setSidebarWidth = useStore(state => state.setSidebarWidth); const aiPanelVisible = useStore(state => state.aiPanelVisible); const toggleAIPanel = useStore(state => state.toggleAIPanel); const setAIPanelVisible = useStore(state => state.setAIPanelVisible); const globalProxyInvalidHintShownRef = React.useRef(false); - const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasAppliedInitialGlobalProxy); + const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig); // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, // 避免 GPU 持续计算窗口背后的模糊合成 @@ -167,6 +171,90 @@ function App() { return; } + let cancelled = false; + const loadSecureConfig = async () => { + const backendApp = (window as any).go?.app?.App; + const persistedPayload = typeof window !== 'undefined' + ? window.localStorage.getItem(LEGACY_PERSIST_KEY) + : null; + const legacy = readLegacyPersistedSecrets(persistedPayload); + + let importedLegacyConnections = false; + let importedLegacyGlobalProxy = false; + + if (legacy.connections.length > 0) { + if (typeof backendApp?.ImportLegacyConnections === 'function') { + try { + await backendApp.ImportLegacyConnections( + legacy.connections.map(({ id, name, config }) => ({ id, name, config })) + ); + importedLegacyConnections = true; + } catch (err) { + console.warn('Failed to import legacy saved connections', err); + } + } else { + replaceConnections(legacy.connections); + } + } + + if (legacy.globalProxy) { + if (typeof backendApp?.ImportLegacyGlobalProxy === 'function') { + try { + await backendApp.ImportLegacyGlobalProxy(toSaveGlobalProxyInput(legacy.globalProxy)); + importedLegacyGlobalProxy = true; + } catch (err) { + console.warn('Failed to import legacy global proxy', err); + } + } else { + replaceGlobalProxy(createGlobalProxyDraft(legacy.globalProxy)); + } + } + + if ((importedLegacyConnections || importedLegacyGlobalProxy) && persistedPayload && typeof window !== 'undefined') { + const sanitizedPayload = stripLegacyPersistedSecrets(persistedPayload); + if (sanitizedPayload && sanitizedPayload !== persistedPayload) { + window.localStorage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload); + } + } + + if (typeof backendApp?.GetSavedConnections === 'function') { + try { + const savedConnections = await backendApp.GetSavedConnections(); + if (!cancelled && Array.isArray(savedConnections)) { + replaceConnections(savedConnections); + } + } catch (err) { + console.warn('Failed to load saved connections from backend', err); + } + } + + if (typeof backendApp?.GetGlobalProxyConfig === 'function') { + try { + const proxyResult = await backendApp.GetGlobalProxyConfig(); + if (!cancelled && proxyResult?.success && proxyResult.data) { + replaceGlobalProxy(createGlobalProxyDraft(proxyResult.data)); + } + } catch (err) { + console.warn('Failed to load global proxy from backend', err); + } + } + + if (!cancelled) { + setHasLoadedSecureConfig(true); + } + }; + + void loadSecureConfig(); + return () => { + cancelled = true; + }; + }, [isStoreHydrated, replaceConnections, replaceGlobalProxy]); + + useEffect(() => { + if (!isStoreHydrated || !hasLoadedSecureConfig) { + return; + } + const host = String(globalProxy.host || '').trim(); const port = Number(globalProxy.port); const portValid = Number.isFinite(port) && port > 0 && port <= 65535; @@ -180,57 +268,44 @@ function App() { }); globalProxyInvalidHintShownRef.current = true; } - } else { - globalProxyInvalidHintShownRef.current = false; - void message.destroy('global-proxy-invalid'); + return; } - const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled; - let cancelled = false; - try { - ConfigureGlobalProxy(enabledForBackend, { - type: globalProxy.type, - host, - port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080), - user: String(globalProxy.user || '').trim(), - password: globalProxy.password || '', - }) - .then((res) => { - if (cancelled || res?.success) { - return; - } - void message.error({ - content: '全局代理配置失败: ' + (res?.message || '未知错误'), - key: 'global-proxy-sync-error', - }); - }) - .catch((err) => { - if (cancelled) { - return; - } - const errMsg = err instanceof Error ? err.message : String(err || '未知错误'); - void message.error({ - content: '全局代理配置失败: ' + errMsg, - key: 'global-proxy-sync-error', - }); - }) - .finally(() => { - if (!cancelled) { - setHasAppliedInitialGlobalProxy(true); - } - }); - } catch (e) { - if (!cancelled) { - setHasAppliedInitialGlobalProxy(true); - } - console.warn("Wails API: ConfigureGlobalProxy unavailable", e); + globalProxyInvalidHintShownRef.current = false; + void message.destroy('global-proxy-invalid'); + + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.SaveGlobalProxy !== 'function') { + return; } + let cancelled = false; + Promise.resolve( + backendApp.SaveGlobalProxy( + toSaveGlobalProxyInput({ + ...globalProxy, + host, + port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080), + }) + ) + ) + .catch((err) => { + if (cancelled) { + return; + } + const errMsg = err instanceof Error ? err.message : String(err || '未知错误'); + void message.error({ + content: '全局代理配置失败: ' + errMsg, + key: 'global-proxy-sync-error', + }); + }); + return () => { cancelled = true; }; }, [ isStoreHydrated, + hasLoadedSecureConfig, globalProxy.enabled, globalProxy.type, globalProxy.host, @@ -2490,3 +2565,5 @@ function App() { } export default App; + + diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 021c764..32ca88b 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem } from './types'; +import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig } from './types'; import { ShortcutAction, ShortcutBinding, @@ -9,6 +9,7 @@ import { cloneShortcutOptions, sanitizeShortcutOptions, } from './utils/shortcuts'; +import { toPersistedGlobalProxy } from './utils/globalProxyDraft'; const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false }; const DEFAULT_UI_SCALE = 1.0; @@ -34,6 +35,7 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = { port: 1080, user: '', password: '', + hasPassword: false, }; const SUPPORTED_CONNECTION_TYPES = new Set([ 'mysql', @@ -246,6 +248,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { const safeConfig: ConnectionConfig & Record = { ...raw, + id: toTrimmedString(raw.id ?? raw.ID), type, host: toTrimmedString(raw.host, 'localhost') || 'localhost', port: normalizePort(raw.port, defaultPort), @@ -321,7 +324,16 @@ const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection return { id, name, - config, + config: { ...config, id: config.id || id }, + secretRef: toTrimmedString(raw.secretRef) || undefined, + hasPrimaryPassword: raw.hasPrimaryPassword === true, + hasSSHPassword: raw.hasSSHPassword === true, + hasProxyPassword: raw.hasProxyPassword === true, + hasHttpTunnelPassword: raw.hasHttpTunnelPassword === true, + hasMySQLReplicaPassword: raw.hasMySQLReplicaPassword === true, + hasMongoReplicaPassword: raw.hasMongoReplicaPassword === true, + hasOpaqueURI: raw.hasOpaqueURI === true, + hasOpaqueDSN: raw.hasOpaqueDSN === true, includeDatabases: includeDatabases.length > 0 ? includeDatabases : undefined, includeRedisDatabases: includeRedisDatabases.length > 0 ? includeRedisDatabases : undefined, }; @@ -393,10 +405,6 @@ export interface QueryOptions { showColumnType: boolean; } -export interface GlobalProxyConfig extends ProxyConfig { - enabled: boolean; -} - interface AppState { connections: SavedConnection[]; connectionTags: ConnectionTag[]; @@ -440,6 +448,7 @@ interface AppState { addConnection: (conn: SavedConnection) => void; updateConnection: (conn: SavedConnection) => void; removeConnection: (id: string) => void; + replaceConnections: (connections: SavedConnection[]) => void; addConnectionTag: (tag: ConnectionTag) => void; updateConnectionTag: (tag: ConnectionTag) => void; @@ -468,6 +477,7 @@ interface AppState { setFontSize: (size: number) => void; setStartupFullscreen: (enabled: boolean) => void; setGlobalProxy: (proxy: Partial) => void; + replaceGlobalProxy: (proxy: Partial) => void; setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void; setQueryOptions: (options: Partial) => void; updateShortcut: (action: ShortcutAction, binding: Partial) => void; @@ -618,18 +628,24 @@ const sanitizeFontSize = (value: unknown): number => { return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE); }; -const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => { +const sanitizeGlobalProxy = ( + value: unknown, + options: { allowPassword?: boolean } = {} +): GlobalProxyConfig => { const raw = (value && typeof value === 'object') ? value as Record : {}; const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase(); const type: 'socks5' | 'http' = typeRaw === 'http' ? 'http' : 'socks5'; const fallbackPort = type === 'http' ? 8080 : 1080; + const password = toTrimmedString(raw.password); return { enabled: raw.enabled === true, type, host: toTrimmedString(raw.host), port: normalizePort(raw.port, fallbackPort), user: toTrimmedString(raw.user), - password: toTrimmedString(raw.password), + password: options.allowPassword === false ? '' : password, + hasPassword: raw.hasPassword === true || password !== '', + secretRef: toTrimmedString(raw.secretRef) || undefined, }; }; @@ -782,6 +798,7 @@ export const useStore = create()( connectionIds: tag.connectionIds.filter(cid => cid !== id) })) })), + replaceConnections: (connections) => set({ connections: sanitizeConnections(connections) }), addConnectionTag: (tag) => set((state) => ({ connectionTags: [...state.connectionTags, tag] })), updateConnectionTag: (tag) => set((state) => ({ @@ -963,6 +980,7 @@ export const useStore = create()( setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }), setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }), setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })), + replaceGlobalProxy: (proxy) => set({ globalProxy: sanitizeGlobalProxy({ ...DEFAULT_GLOBAL_PROXY, ...proxy }) }), setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }), setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })), updateShortcut: (action, binding) => set((state) => ({ @@ -1203,7 +1221,7 @@ export const useStore = create()( migrate: (persistedState: unknown, version: number) => { const state = unwrapPersistedAppState(persistedState) as Partial; const nextState: Partial = { ...state }; - nextState.connections = sanitizeConnections(state.connections); + nextState.connections = []; if (version < 5) { nextState.connectionTags = sanitizeConnectionTags(state.connectionTags); } else { @@ -1215,7 +1233,7 @@ export const useStore = create()( nextState.uiScale = sanitizeUiScale(state.uiScale); nextState.fontSize = sanitizeFontSize(state.fontSize); nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen); - nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy); + nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy, { allowPassword: false }); nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions); nextState.queryOptions = sanitizeQueryOptions(state.queryOptions); nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions); @@ -1242,7 +1260,7 @@ export const useStore = create()( return { ...currentState, ...state, - connections: sanitizeConnections(state.connections), + connections: currentState.connections, connectionTags: sanitizeConnectionTags(state.connectionTags), savedQueries: sanitizeSavedQueries(state.savedQueries), theme: sanitizeTheme(state.theme), @@ -1250,7 +1268,7 @@ export const useStore = create()( uiScale: sanitizeUiScale(state.uiScale), fontSize: sanitizeFontSize(state.fontSize), startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen), - globalProxy: sanitizeGlobalProxy(state.globalProxy), + globalProxy: sanitizeGlobalProxy(state.globalProxy, { allowPassword: false }), tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference), tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders), enableColumnOrderMemory: state.enableColumnOrderMemory !== false, @@ -1271,7 +1289,6 @@ export const useStore = create()( }; }, partialize: (state) => ({ - connections: state.connections, connectionTags: state.connectionTags, savedQueries: state.savedQueries, theme: state.theme, @@ -1279,7 +1296,7 @@ export const useStore = create()( uiScale: state.uiScale, fontSize: state.fontSize, startupFullscreen: state.startupFullscreen, - globalProxy: state.globalProxy, + globalProxy: toPersistedGlobalProxy(state.globalProxy), sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions, shortcutOptions: state.shortcutOptions, @@ -1298,3 +1315,5 @@ export const useStore = create()( } ) ); + + diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 88c2fb4..34db0ec 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -22,6 +22,7 @@ export interface HTTPTunnelConfig { } export interface ConnectionConfig { + id?: string; type: string; host: string; port: number; @@ -70,12 +71,27 @@ export interface SavedConnection { id: string; name: string; config: ConnectionConfig; + secretRef?: string; + hasPrimaryPassword?: boolean; + hasSSHPassword?: boolean; + hasProxyPassword?: boolean; + hasHttpTunnelPassword?: boolean; + hasMySQLReplicaPassword?: boolean; + hasMongoReplicaPassword?: boolean; + hasOpaqueURI?: boolean; + hasOpaqueDSN?: boolean; includeDatabases?: string[]; includeRedisDatabases?: number[]; // Redis databases to show (0-15) iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色 } +export interface GlobalProxyConfig extends ProxyConfig { + enabled: boolean; + hasPassword?: boolean; + secretRef?: string; +} + export interface ConnectionTag { id: string; name: string; @@ -243,3 +259,5 @@ export interface AISafetyResult { requiresConfirm: boolean; warningMessage?: string; } + + diff --git a/frontend/src/utils/globalProxyDraft.test.ts b/frontend/src/utils/globalProxyDraft.test.ts new file mode 100644 index 0000000..7354ca7 --- /dev/null +++ b/frontend/src/utils/globalProxyDraft.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { createGlobalProxyDraft, toPersistedGlobalProxy } from './globalProxyDraft'; + +describe('global proxy draft', () => { + it('hydrates a secretless draft from backend metadata while keeping password input blank', () => { + const draft = createGlobalProxyDraft({ + enabled: true, + type: 'http', + host: '127.0.0.1', + port: 8080, + user: 'ops', + hasPassword: true, + password: 'should-be-ignored', + }); + + expect(draft.password).toBe(''); + expect(draft.hasPassword).toBe(true); + }); + + it('drops password from persisted metadata but preserves hasPassword', () => { + const persisted = toPersistedGlobalProxy({ + enabled: true, + type: 'http', + host: '127.0.0.1', + port: 8080, + user: 'ops', + password: 'proxy-secret', + hasPassword: true, + }); + + expect('password' in persisted).toBe(false); + expect(persisted.hasPassword).toBe(true); + }); +}); diff --git a/frontend/src/utils/globalProxyDraft.ts b/frontend/src/utils/globalProxyDraft.ts new file mode 100644 index 0000000..408635c --- /dev/null +++ b/frontend/src/utils/globalProxyDraft.ts @@ -0,0 +1,62 @@ +import { GlobalProxyConfig } from '../types'; + +const toTrimmedString = (value: unknown): string => { + if (typeof value === 'string') { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); + } + return ''; +}; + +const normalizeProxyType = (value: unknown): 'socks5' | 'http' => { + return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5'; +}; + +const normalizePort = (value: unknown, fallbackPort: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallbackPort; + } + const port = Math.trunc(parsed); + if (port <= 0 || port > 65535) { + return fallbackPort; + } + return port; +}; + +export function createGlobalProxyDraft(value: Partial = {}): GlobalProxyConfig { + const type = normalizeProxyType(value.type); + return { + enabled: value.enabled === true, + type, + host: toTrimmedString(value.host), + port: normalizePort(value.port, type === 'http' ? 8080 : 1080), + user: toTrimmedString(value.user), + password: '', + hasPassword: value.hasPassword === true, + secretRef: toTrimmedString(value.secretRef) || undefined, + }; +} + +export function toPersistedGlobalProxy(value: Partial = {}): Omit { + const draft = createGlobalProxyDraft(value); + return { + enabled: draft.enabled, + type: draft.type, + host: draft.host, + port: draft.port, + user: draft.user, + hasPassword: draft.hasPassword, + secretRef: draft.secretRef, + }; +} + +export function toSaveGlobalProxyInput(value: Partial = {}): GlobalProxyConfig { + const draft = createGlobalProxyDraft(value); + return { + ...draft, + password: typeof value.password === 'string' ? value.password : '', + }; +} diff --git a/frontend/src/utils/legacyConnectionStorage.test.ts b/frontend/src/utils/legacyConnectionStorage.test.ts new file mode 100644 index 0000000..7f8a46b --- /dev/null +++ b/frontend/src/utils/legacyConnectionStorage.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './legacyConnectionStorage'; + +describe('legacy connection storage', () => { + it('extracts legacy saved connections and global proxy password from lite-db-storage', () => { + const payload = JSON.stringify({ + state: { + connections: [ + { + id: 'conn-1', + name: 'Primary', + config: { + id: 'conn-1', + type: 'postgres', + host: 'db.local', + port: 5432, + user: 'postgres', + password: 'secret', + }, + }, + ], + globalProxy: { + enabled: true, + type: 'http', + host: '127.0.0.1', + port: 8080, + user: 'ops', + password: 'proxy-secret', + }, + }, + }); + + const result = readLegacyPersistedSecrets(payload); + expect(result.connections).toHaveLength(1); + expect(result.connections[0]?.config.password).toBe('secret'); + expect(result.globalProxy?.password).toBe('proxy-secret'); + }); + + it('strips persisted connection secrets but keeps secretless proxy metadata', () => { + const payload = JSON.stringify({ + state: { + connections: [ + { + id: 'conn-1', + name: 'Primary', + config: { + id: 'conn-1', + type: 'postgres', + host: 'db.local', + port: 5432, + user: 'postgres', + password: 'secret', + }, + }, + ], + globalProxy: { + enabled: true, + type: 'http', + host: '127.0.0.1', + port: 8080, + user: 'ops', + password: 'proxy-secret', + }, + }, + }); + + const sanitized = stripLegacyPersistedSecrets(payload); + const parsed = JSON.parse(sanitized); + + expect(parsed.state.connections).toEqual([]); + expect(parsed.state.globalProxy.password).toBeUndefined(); + expect(parsed.state.globalProxy.hasPassword).toBe(true); + }); +}); diff --git a/frontend/src/utils/legacyConnectionStorage.ts b/frontend/src/utils/legacyConnectionStorage.ts new file mode 100644 index 0000000..cdbdd6c --- /dev/null +++ b/frontend/src/utils/legacyConnectionStorage.ts @@ -0,0 +1,110 @@ +import { GlobalProxyConfig, SavedConnection } from '../types'; + +export const LEGACY_PERSIST_KEY = 'lite-db-storage'; + +const toTrimmedString = (value: unknown): string => { + if (typeof value === 'string') { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); + } + return ''; +}; + +const normalizeProxyType = (value: unknown): 'socks5' | 'http' => { + return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5'; +}; + +const normalizePort = (value: unknown, fallbackPort: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallbackPort; + } + const port = Math.trunc(parsed); + if (port <= 0 || port > 65535) { + return fallbackPort; + } + return port; +}; + +const parsePersistedEnvelope = (payload: string | null | undefined): Record => { + if (!payload || typeof payload !== 'string') { + return {}; + } + try { + const parsed = JSON.parse(payload) as Record; + if (parsed.state && typeof parsed.state === 'object') { + return parsed.state as Record; + } + return parsed; + } catch { + return {}; + } +}; + +export function readLegacyPersistedSecrets(payload: string | null | undefined): { + connections: SavedConnection[]; + globalProxy: GlobalProxyConfig | null; +} { + const state = parsePersistedEnvelope(payload); + const connections = Array.isArray(state.connections) + ? state.connections.filter((item): item is SavedConnection => !!item && typeof item === 'object') + : []; + + const proxyRaw = state.globalProxy && typeof state.globalProxy === 'object' + ? state.globalProxy as Record + : null; + if (!proxyRaw) { + return { connections, globalProxy: null }; + } + + const type = normalizeProxyType(proxyRaw.type); + const password = toTrimmedString(proxyRaw.password); + const globalProxy: GlobalProxyConfig = { + enabled: proxyRaw.enabled === true, + type, + host: toTrimmedString(proxyRaw.host), + port: normalizePort(proxyRaw.port, type === 'http' ? 8080 : 1080), + user: toTrimmedString(proxyRaw.user), + password, + hasPassword: proxyRaw.hasPassword === true || password !== '', + secretRef: toTrimmedString(proxyRaw.secretRef) || undefined, + }; + + const hasMeaningfulProxyState = globalProxy.enabled || globalProxy.host !== '' || globalProxy.user !== '' || globalProxy.password !== '' || globalProxy.hasPassword === true; + return { + connections, + globalProxy: hasMeaningfulProxyState ? globalProxy : null, + }; +} + +export function stripLegacyPersistedSecrets(payload: string | null | undefined): string { + if (!payload || typeof payload !== 'string') { + return ''; + } + + let parsed: Record; + try { + parsed = JSON.parse(payload) as Record; + } catch { + return payload; + } + + const state = parsed.state && typeof parsed.state === 'object' + ? parsed.state as Record + : parsed; + state.connections = []; + + if (state.globalProxy && typeof state.globalProxy === 'object') { + const proxy = { ...(state.globalProxy as Record) }; + const password = toTrimmedString(proxy.password); + delete proxy.password; + if (password !== '') { + proxy.hasPassword = true; + } + state.globalProxy = proxy; + } + + return JSON.stringify(parsed); +} diff --git a/frontend/src/utils/startupReadiness.test.ts b/frontend/src/utils/startupReadiness.test.ts index 92c72bd..3b34e7e 100644 --- a/frontend/src/utils/startupReadiness.test.ts +++ b/frontend/src/utils/startupReadiness.test.ts @@ -10,10 +10,10 @@ describe('startup readiness helpers', () => { }); }); - it('keeps sidebar blocked until initial global proxy sync finishes', () => { + it('keeps sidebar blocked until secure config bootstrap finishes', () => { expect(getConnectionWorkbenchState(true, false)).toEqual({ ready: false, - message: '正在同步全局代理配置...', + message: '正在加载安全配置...', }); }); @@ -24,3 +24,4 @@ describe('startup readiness helpers', () => { }); }); }); + diff --git a/frontend/src/utils/startupReadiness.ts b/frontend/src/utils/startupReadiness.ts index 3395627..c7aab1d 100644 --- a/frontend/src/utils/startupReadiness.ts +++ b/frontend/src/utils/startupReadiness.ts @@ -16,7 +16,7 @@ export function getConnectionWorkbenchState( if (!hasAppliedInitialGlobalProxy) { return { ready: false, - message: '正在同步全局代理配置...', + message: '正在加载安全配置...', }; } return { @@ -24,3 +24,4 @@ export function getConnectionWorkbenchState( message: '', }; } +