From e879d133da3cceff4e30a90caf317ea690edf605 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 10 Jul 2026 23:36:54 +0200 Subject: [PATCH] fix(desktop): allow remote gateway token storage on keyring-less Linux On Linux without a Secret Service keyring (e.g. Hyprland/Sway with no GNOME Keyring or KWallet), safeStorage.isEncryptionAvailable() is false, so saving a remote gateway session token from Settings -> Gateway failed hard with no in-app way forward. - encryptDesktopSecret gains an explicit allowPlainText opt-in: when secure storage is unavailable and the user confirmed the prompt, the token persists as { encoding: 'plain' } in connection.json (which decryptDesktopSecret already round-trips). - Settings -> Gateway now surfaces the opt-in: a destructive confirm dialog before persisting a token in plain text, and a persistent warning banner while the saved token is stored unencrypted. Localized in en/ja/zh/zh-hant. - The connection-config IPC response reports secureTokenStorage and remoteTokenPlainText so the renderer can drive both affordances. - Launching with --password-store=basic now works: on Linux the app calls safeStorage.setUsePlainTextEncryption(true) at startup when the switch is set, which Electron requires for the basic backend to count as available. - The no-opt-in error now spells out all three remedies (enable an OS keyring, confirm plain-text storage, or use HERMES_DESKTOP_REMOTE_URL/ HERMES_DESKTOP_REMOTE_TOKEN). Fixes #62294 --- apps/desktop/electron/hardening.test.ts | 34 ++++ apps/desktop/electron/hardening.ts | 20 ++- apps/desktop/electron/main.ts | 44 ++++- .../src/app/settings/gateway-settings.tsx | 153 +++++++++++++----- .../components/boot-failure-reauth.test.ts | 2 + apps/desktop/src/global.d.ts | 12 ++ apps/desktop/src/i18n/en.ts | 7 + apps/desktop/src/i18n/ja.ts | 7 + apps/desktop/src/i18n/types.ts | 5 + apps/desktop/src/i18n/zh-hant.ts | 7 + apps/desktop/src/i18n/zh.ts | 7 + apps/desktop/src/store/notifications.ts | 4 +- 12 files changed, 256 insertions(+), 46 deletions(-) diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts index 1a5852f720..4aa6e65076 100644 --- a/apps/desktop/electron/hardening.test.ts +++ b/apps/desktop/electron/hardening.test.ts @@ -55,6 +55,40 @@ test('encryptDesktopSecret stores safeStorage base64 payload', () => { }) }) +test('encryptDesktopSecret allows plain-text opt-in when encryption is unavailable', () => { + const secret = encryptDesktopSecret( + 'token', + { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }, + { allowPlainText: true } + ) + + assert.deepEqual(secret, { encoding: 'plain', value: 'token' }) +}) + +test('encryptDesktopSecret keeps encrypting when available even with the plain-text opt-in', () => { + const secret = encryptDesktopSecret( + 'token-123', + { isEncryptionAvailable: () => true, encryptString: value => Buffer.from(`enc:${value}`, 'utf8') }, + { allowPlainText: true } + ) + + assert.deepEqual(secret, { + encoding: 'safeStorage', + value: Buffer.from('enc:token-123', 'utf8').toString('base64') + }) +}) + +test('encryptDesktopSecret returns null for an empty value even with the plain-text opt-in', () => { + assert.equal( + encryptDesktopSecret( + '', + { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }, + { allowPlainText: true } + ), + null + ) +}) + test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index 2d6b533100..9673f314f0 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -23,13 +23,18 @@ function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { return fallback } -function encryptDesktopSecret(value, safeStorageApi) { +function encryptDesktopSecret(value, safeStorageApi, options: { allowPlainText?: boolean } = {}) { const raw = String(value || '') if (!raw) { return null } + // Opt-in escape hatch for keyring-less Linux (e.g. Hyprland/Sway with no + // GNOME Keyring or KWallet): the renderer sets this once the user confirms + // the plain-text storage prompt in Settings → Gateway. + const allowPlainText = options?.allowPlainText === true + let encryptionAvailable = false try { @@ -39,9 +44,18 @@ function encryptDesktopSecret(value, safeStorageApi) { } if (!encryptionAvailable) { + // Only downgrade to plain text when the user has explicitly opted in; + // decryptDesktopSecret returns the raw value for any non-'safeStorage' + // encoding, so this round-trips without any decrypt-side change. + if (allowPlainText) { + return { encoding: 'plain', value: raw } + } + throw new Error( - 'Secure token storage is unavailable, so Hermes Desktop cannot save remote gateway tokens. ' + - 'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment, or enable OS keychain access and try again.' + 'Secure token storage is unavailable (no OS keyring service was found), so Hermes Desktop cannot save remote gateway tokens. ' + + 'Either enable an OS keyring (e.g. GNOME Keyring or KWallet providing org.freedesktop.secrets) and try again, ' + + 'confirm the plain-text storage option when prompted in Settings → Gateway, ' + + 'or set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment.' ) } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 646eefc6eb..bdf88007be 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -5909,8 +5909,8 @@ async function cloudAgentSilentSignIn(dashboardUrl) { return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } } -function encryptDesktopSecret(value) { - return encryptDesktopSecretStrict(value, safeStorage) +function encryptDesktopSecret(value, options) { + return encryptDesktopSecretStrict(value, safeStorage, options) } function decryptDesktopSecret(secret) { @@ -6098,6 +6098,23 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const savedMode = key ? scoped?.mode : config.mode const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local' + // Whether the OS keyring (safeStorage) can encrypt the saved token. When + // false the renderer knows to offer the plain-text opt-in in Settings → + // Gateway. safeStorage.isEncryptionAvailable can throw on some platforms, so + // treat any failure as "not available". + let secureTokenStorage = false + + try { + secureTokenStorage = Boolean(safeStorage.isEncryptionAvailable()) + } catch { + secureTokenStorage = false + } + + // Whether the currently saved token is stored in plain text (the keyring-less + // opt-in path). The env override supplies its token from the environment, not + // the saved block, so it never reports as plain text here. + const remoteTokenPlainText = !envOverride && block.token?.encoding === 'plain' + let remoteOauthConnected = false if (authMode === 'oauth' && remoteUrl) { @@ -6124,6 +6141,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon cloudOrg: mode === 'cloud' ? String(block.org || '') : '', remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), + // Whether the OS keyring can encrypt a token; drives the plain-text opt-in + // affordance in Settings → Gateway on keyring-less Linux. + secureTokenStorage, + // Whether the saved token is currently persisted in plain text. + remoteTokenPlainText, // The env override only forces the global/primary connection; a per-profile // scope is never overridden by HERMES_DESKTOP_REMOTE_URL. envOverride @@ -6187,7 +6209,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo const nextToken = incomingToken ? persistToken - ? encryptDesktopSecret(incomingToken) + ? encryptDesktopSecret(incomingToken, { allowPlainText: input.allowPlainTextToken === true }) : { encoding: 'plain', value: incomingToken } : existingBlock.token @@ -9541,6 +9563,22 @@ app.whenReady().then(() => { rememberLog(`[tls] could not load Windows system CA certificates: ${systemCa.error}`) } + // Keyring-less Linux (e.g. Hyprland/Sway with no GNOME Keyring or KWallet): + // `--password-store=basic` selects Electron's built-in "basic" backend, but + // Electron only counts it as available once setUsePlainTextEncryption(true) + // is called. Do this before createWindow() and anything that could touch + // safeStorage. Older/mocked safeStorage may lack the method, so guard + wrap. + if (process.platform === 'linux' && app.commandLine.getSwitchValue('password-store') === 'basic') { + try { + if (typeof safeStorage.setUsePlainTextEncryption === 'function') { + safeStorage.setUsePlainTextEncryption(true) + } + } catch { + // Non-fatal: encryption simply stays unavailable and the user can fall + // back to the plain-text opt-in or the HERMES_DESKTOP_REMOTE_* env vars. + } + } + if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) } else { diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index ca7ce2384f..a70f1eceb3 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Input } from '@/components/ui/input' import { Tip } from '@/components/ui/tooltip' import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' @@ -10,7 +11,7 @@ import { ExternalLink } from '@/lib/external-link' import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' -import { notify, notifyError } from '@/store/notifications' +import { notify, notifyError, readableError } from '@/store/notifications' import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' @@ -29,6 +30,12 @@ interface GatewaySettingsState { remoteOauthConnected: boolean remoteTokenPreview: string | null remoteTokenSet: boolean + // Whether OS-keychain-backed encryption (Electron safeStorage) is available. + // Default true so we never gate on a value we haven't hydrated yet. + secureTokenStorage: boolean + // Whether the currently-persisted remote token is stored as plain text on + // disk (opted-in on a machine without secure storage). Drives the warning banner. + remoteTokenPlainText: boolean remoteUrl: string cloudOrg: string } @@ -40,6 +47,8 @@ const EMPTY_STATE: GatewaySettingsState = { remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, + secureTokenStorage: true, + remoteTokenPlainText: false, remoteUrl: '', cloudOrg: '' } @@ -135,6 +144,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setConnectedCloudUrl(savedCloudConnectionUrl(config)) } + // When set, the plain-text opt-in dialog is open; `apply` remembers whether + // the gated action was Save-for-restart (false) or Save-and-reconnect (true) + // so confirm resumes the right one. + const [plainTextConfirm, setPlainTextConfirm] = useState(null) + // --- Hermes Cloud (cloud mode) state --- // One portal session powers discovery + the silent per-agent cascade. These // track the cloud panel: whether we're signed in, the discovered agent list, @@ -351,14 +365,54 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { return Boolean(remoteToken.trim()) || state.remoteTokenSet }, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl]) - const payload = () => ({ + const payload = (allowPlainTextToken?: boolean) => ({ mode: state.mode, profile: scope ?? undefined, remoteAuthMode: authMode, remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl + remoteUrl: trimmedUrl, + ...(allowPlainTextToken ? { allowPlainTextToken: true } : {}) }) + // A pending Save/Apply would write a NEW token to disk in plain text when + // we're on a remote-like connection using token auth, the user typed a token, + // and this machine has no OS keyring (safeStorage unavailable). In that case + // we must get an explicit opt-in before persisting. + const wouldPersistPlainTextToken = + (state.mode === 'remote' || state.mode === 'cloud') && + authMode !== 'oauth' && + Boolean(remoteToken.trim()) && + state.secureTokenStorage === false + + const performSave = async (apply: boolean, allowPlainTextToken: boolean) => { + setSaving(true) + + try { + const next = apply + ? await window.hermesDesktop.applyConnectionConfig(payload(allowPlainTextToken)) + : await window.hermesDesktop.saveConnectionConfig(payload(allowPlainTextToken)) + + acceptSavedConfig(next) + setRemoteToken('') + notify({ + kind: 'success', + title: apply ? g.restartingTitle : g.savedTitle, + message: apply ? g.restartingMessage : g.savedMessage + }) + } catch (err) { + // The plain-text opt-in path runs inside ConfirmDialog's onConfirm, which + // keeps the dialog open with an inline error when it throws — rethrow a + // readable message there so a failed save can't play the success beat. + if (allowPlainTextToken) { + throw new Error(readableError(err, apply ? g.applyFailed : g.saveFailed).message) + } + + notifyError(err, apply ? g.applyFailed : g.saveFailed) + } finally { + setSaving(false) + } + } + const save = async (apply: boolean) => { if (state.mode === 'remote' && !canUseRemote) { notify({ @@ -370,25 +424,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { return } - setSaving(true) + // Defer to the opt-in dialog; confirm resumes with allowPlainTextToken. + if (wouldPersistPlainTextToken) { + setPlainTextConfirm({ apply }) - try { - const next = apply - ? await window.hermesDesktop.applyConnectionConfig(payload()) - : await window.hermesDesktop.saveConnectionConfig(payload()) - - acceptSavedConfig(next) - setRemoteToken('') - notify({ - kind: 'success', - title: apply ? g.restartingTitle : g.savedTitle, - message: apply ? g.restartingMessage : g.savedMessage - }) - } catch (err) { - notifyError(err, apply ? g.applyFailed : g.saveFailed) - } finally { - setSaving(false) + return } + + await performSave(apply, false) } // OAuth sign-in: persist the URL + oauth mode first (so the saved config has @@ -1010,25 +1053,39 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {/* Session-token gateways: keep the existing token entry box. */} {state.mode === 'remote' && authResolved && authMode === 'token' ? ( - setRemoteToken(event.target.value)} - placeholder={ - state.remoteTokenSet - ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) - : g.pasteSessionToken - } - type="password" - value={remoteToken} - /> - } - description={g.tokenDesc} - title={g.tokenTitle} - /> + <> + setRemoteToken(event.target.value)} + placeholder={ + state.remoteTokenSet + ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) + : g.pasteSessionToken + } + type="password" + value={remoteToken} + /> + } + description={g.tokenDesc} + title={g.tokenTitle} + /> + + {/* The saved token is on disk in plain text (no OS keyring). Same + banner idiom as envOverride so it reads as a real warning. */} + {state.remoteTokenPlainText ? ( +
+ +
+
{g.plainTextStoredTitle}
+
{g.plainTextStoredDesc}
+
+
+ ) : null} + ) : null} ) : null} @@ -1083,6 +1140,24 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { /> )} + + {/* Plain-text token opt-in: gated when secure storage is unavailable and a + new token would be persisted. Confirm resumes the remembered save/apply. */} + setPlainTextConfirm(null)} + onConfirm={async () => { + if (!plainTextConfirm) { + return + } + + await performSave(plainTextConfirm.apply, true) + }} + open={plainTextConfirm !== null} + title={g.plainTextConfirmTitle} + /> ) } diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 5d198c96e4..f1a2163fc0 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -19,6 +19,8 @@ function config(overrides: Partial = {}): DesktopConnec remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, + secureTokenStorage: true, + remoteTokenPlainText: false, remoteUrl: 'https://box:9119', cloudOrg: '', ...overrides diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a37091ceeb..dd731f9422 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -429,6 +429,14 @@ export interface DesktopConnectionConfig { remoteOauthConnected: boolean remoteTokenPreview: string | null remoteTokenSet: boolean + // Whether OS-keychain-backed encryption (Electron safeStorage) is currently + // available on this machine. When false, a persisted remote token can only be + // stored as plain text on disk (with an explicit opt-in). + secureTokenStorage: boolean + // Whether the currently-persisted remote token is stored with encoding + // 'plain' (i.e. plain text on disk in connection.json), which happens when + // the user opted in on a machine without secure storage. + remoteTokenPlainText: boolean remoteUrl: string // For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the // connected instance was discovered under, so Settings → Gateway can reopen @@ -443,6 +451,10 @@ export interface DesktopConnectionConfigInput { profile?: null | string remoteAuthMode?: 'oauth' | 'token' remoteToken?: string + // When true and secure (OS-keychain) storage is unavailable, persist the + // remote token as plain text on disk instead of failing. Requires an explicit + // user opt-in from the renderer. + allowPlainTextToken?: boolean remoteUrl?: string // For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to // persist so Settings can reopen into it. Ignored for remote/local modes. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7765c0f746..85700a7756 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -617,6 +617,13 @@ export const en: Translations = { existingToken: value => `Existing token ${value}`, savedToken: 'saved', pasteSessionToken: 'Paste session token', + plainTextConfirmTitle: 'Store the gateway token in plain text?', + plainTextConfirmDesc: + 'No OS keyring service was found on this machine, so the token would be saved unencrypted in the app’s connection settings file, readable by any process running as this user. Install or enable GNOME Keyring or KWallet for encrypted storage.', + plainTextConfirmAction: 'Save as plain text', + plainTextStoredTitle: 'Token stored in plain text', + plainTextStoredDesc: + 'Secure storage is unavailable, so the saved token is stored unencrypted in the app’s connection settings file on this machine. Install or enable GNOME Keyring or KWallet to encrypt it.', testRemote: 'Test remote', saveForRestart: 'Save for next restart', saveAndReconnect: 'Save and reconnect', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index fcc2777620..811865b416 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -689,6 +689,13 @@ export const ja = defineLocale({ existingToken: value => `既存のトークン ${value}`, savedToken: '保存済み', pasteSessionToken: 'セッショントークンを貼り付け', + plainTextConfirmTitle: 'ゲートウェイトークンを平文で保存しますか?', + plainTextConfirmDesc: + 'このマシンで OS のキーリングサービスが見つからなかったため、トークンはアプリの接続設定ファイルに暗号化されずに保存され、このユーザーとして実行される任意のプロセスから読み取れる状態になります。暗号化して保存するには、GNOME Keyring または KWallet をインストールまたは有効化してください。', + plainTextConfirmAction: '平文で保存', + plainTextStoredTitle: 'トークンは平文で保存されています', + plainTextStoredDesc: + 'セキュアストレージが利用できないため、保存済みのトークンはこのマシンのアプリの接続設定ファイルに暗号化されずに保存されています。暗号化するには GNOME Keyring または KWallet をインストールまたは有効化してください。', testRemote: 'リモートをテスト', saveForRestart: '次回起動時のために保存', saveAndReconnect: '保存して再接続', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 40f224f2b2..b1e576276a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -520,6 +520,11 @@ export interface Translations { existingToken: (value: string) => string savedToken: string pasteSessionToken: string + plainTextConfirmTitle: string + plainTextConfirmDesc: string + plainTextConfirmAction: string + plainTextStoredTitle: string + plainTextStoredDesc: string testRemote: string saveForRestart: string saveAndReconnect: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4c543feb98..58a40802ae 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -668,6 +668,13 @@ export const zhHant = defineLocale({ existingToken: value => `現有 Token ${value}`, savedToken: '已儲存', pasteSessionToken: '貼上工作階段 Token', + plainTextConfirmTitle: '以純文字儲存閘道 Token?', + plainTextConfirmDesc: + '在此裝置上找不到作業系統的金鑰環服務,因此 Token 將以未加密的純文字儲存在應用程式的連線設定檔中,以該使用者身分執行的任何處理程序皆可讀取。請安裝或啟用 GNOME Keyring 或 KWallet 以進行加密儲存。', + plainTextConfirmAction: '以純文字儲存', + plainTextStoredTitle: 'Token 以純文字儲存', + plainTextStoredDesc: + '安全儲存無法使用,因此已儲存的 Token 以未加密方式儲存在此裝置上應用程式的連線設定檔中。請安裝或啟用 GNOME Keyring 或 KWallet 以將其加密。', testRemote: '測試遠端', saveForRestart: '儲存至下次重新啟動', saveAndReconnect: '儲存並重新連線', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 07c01b7395..7d64fdf274 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -811,6 +811,13 @@ export const zh: Translations = { existingToken: value => `现有 token ${value}`, savedToken: '已保存', pasteSessionToken: '粘贴会话 token', + plainTextConfirmTitle: '以明文存储网关 token?', + plainTextConfirmDesc: + '在此设备上未找到操作系统的密钥环服务,因此 token 将以未加密的明文保存在应用的连接设置文件中,以该用户身份运行的任何进程都可读取。请安装或启用 GNOME Keyring 或 KWallet 以进行加密存储。', + plainTextConfirmAction: '以明文保存', + plainTextStoredTitle: 'Token 以明文存储', + plainTextStoredDesc: + '安全存储不可用,因此已保存的 token 以未加密方式存储在此设备上应用的连接设置文件中。请安装或启用 GNOME Keyring 或 KWallet 以对其加密。', testRemote: '测试远程', saveForRestart: '保存到下次重启', saveAndReconnect: '保存并重连', diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index 82a67e9731..d1213bfc18 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -112,7 +112,9 @@ function summarizeErrorMessage(message: string, fallback: string) { return message.length > 180 ? fallback : message || fallback } -function readableError(error: unknown, fallback: string): { message: string; detail?: string } { +// Exported so flows that surface errors inline (e.g. ConfirmDialog's onConfirm +// rethrow) can reuse the same IPC-unwrapping + summarizing as notifyError. +export function readableError(error: unknown, fallback: string): { message: string; detail?: string } { const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback const unwrapped = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw const cleaned = cleanErrorText(unwrapped)