vi.fn())
+const onFocusChanged = vi.hoisted(() => vi.fn())
+const isVisible = vi.hoisted(() => vi.fn())
+const isMinimized = vi.hoisted(() => vi.fn())
+
+vi.mock('@/services/cmds', () => ({ getPendingFailures }))
+vi.mock('@tauri-apps/api/window', () => ({
+ getCurrentWindow: () => ({ onFocusChanged, isVisible, isMinimized }),
+}))
+
+let table: PendingFailure[] = []
+let onSubscribed: (() => void) | undefined
+let onTableChanged: (() => void) | undefined
+let focusListener: ((event: { payload: boolean }) => void) | undefined
+
+vi.mock('@/services/events', () => ({
+ subscribeVergeEvents: (
+ handlers: Record
void>,
+ subscribed?: () => void,
+ ) => {
+ onTableChanged = handlers['verge://pending-failures-changed']
+ onSubscribed = subscribed
+ return () => {}
+ },
+}))
+
+const failure = (sequence: number, detail: string): PendingFailure => ({
+ code: 'SYSPROXY_GUARD_STOPPED',
+ detail,
+ operation: 'systemProxyGuard',
+ sequence,
+})
+
+const dialogFailure = (sequence: number): PendingFailure => ({
+ code: 'SYSPROXY_PRIVILEGE_REQUIRED',
+ detail: 'refused',
+ operation: 'systemProxyEnable',
+ sequence,
+})
+
+const shownDetails = () =>
+ getSnapshotNotices().map((notice) => notice.i18n?.params?.message)
+
+beforeEach(() => {
+ getPendingFailures.mockClear()
+ onFocusChanged.mockClear()
+ isVisible.mockReset()
+ isVisible.mockResolvedValue(true)
+ isMinimized.mockReset()
+ isMinimized.mockResolvedValue(false)
+ table = []
+ onSubscribed = undefined
+ onTableChanged = undefined
+ focusListener = undefined
+ getPendingFailures.mockImplementation(() => Promise.resolve(table))
+ onFocusChanged.mockImplementation(
+ (listener: (event: { payload: boolean }) => void) => {
+ focusListener = listener
+ return Promise.resolve(() => {})
+ },
+ )
+})
+
+afterEach(() => {
+ cleanup()
+ getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
+})
+
+it('shows a failure that was recorded before the listeners were live', async () => {
+ table = [failure(1, 'refused')]
+ renderHook(() => usePendingFailures())
+
+ onSubscribed?.()
+
+ await waitFor(() => expect(shownDetails()).toEqual(['refused']))
+})
+
+it('shows a failure recorded after the window was already up', async () => {
+ renderHook(() => usePendingFailures())
+ onSubscribed?.()
+ await waitFor(() => expect(getPendingFailures).toHaveBeenCalled())
+
+ table = [failure(1, 'refused')]
+ onTableChanged?.()
+
+ await waitFor(() => expect(shownDetails()).toEqual(['refused']))
+})
+
+it('does not show the same failure again when asked again', async () => {
+ table = [failure(1, 'refused')]
+ renderHook(() => usePendingFailures())
+
+ onSubscribed?.()
+ await waitFor(() => expect(shownDetails()).toEqual(['refused']))
+ onTableChanged?.()
+ focusListener?.({ payload: true })
+
+ await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(3))
+ expect(shownDetails()).toEqual(['refused'])
+})
+
+it('shows the same code again when it has failed again', async () => {
+ table = [failure(1, 'refused')]
+ renderHook(() => usePendingFailures())
+ onSubscribed?.()
+ await waitFor(() => expect(shownDetails()).toEqual(['refused']))
+
+ table = [failure(2, 'refused again')]
+ onTableChanged?.()
+
+ await waitFor(() => expect(shownDetails()).toEqual(['refused again']))
+})
+
+it('reads when the window takes focus', async () => {
+ renderHook(() => usePendingFailures())
+ onSubscribed?.()
+ await waitFor(() => expect(getPendingFailures).toHaveBeenCalled())
+
+ table = [failure(1, 'refused')]
+ focusListener?.({ payload: true })
+
+ await waitFor(() => expect(shownDetails()).toEqual(['refused']))
+})
+
+it('keeps a failure pending while the window is hidden', async () => {
+ isVisible.mockResolvedValue(false)
+ table = [failure(1, 'refused')]
+ renderHook(() => usePendingFailures())
+
+ onSubscribed?.()
+ await waitFor(() => expect(isVisible).toHaveBeenCalled())
+ expect(shownDetails()).toEqual([])
+
+ isVisible.mockResolvedValue(true)
+ focusListener?.({ payload: true })
+
+ await waitFor(() => expect(shownDetails()).toEqual(['refused']))
+})
+
+it('keeps a failure pending while the window is minimised', async () => {
+ isMinimized.mockResolvedValue(true)
+ table = [failure(1, 'refused')]
+ renderHook(() => usePendingFailures())
+
+ onSubscribed?.()
+ await waitFor(() => expect(isMinimized).toHaveBeenCalled())
+ expect(shownDetails()).toEqual([])
+
+ isMinimized.mockResolvedValue(false)
+ focusListener?.({ payload: true })
+
+ await waitFor(() => expect(shownDetails()).toEqual(['refused']))
+})
+
+it('keeps a failure pending when the window cannot say what it is doing', async () => {
+ isVisible.mockRejectedValue(new Error('no window'))
+ table = [failure(1, 'refused')]
+ renderHook(() => usePendingFailures())
+
+ onSubscribed?.()
+ await waitFor(() => expect(isVisible).toHaveBeenCalled())
+ expect(shownDetails()).toEqual([])
+})
+
+it('ignores losing focus', async () => {
+ renderHook(() => usePendingFailures())
+ onSubscribed?.()
+ await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(1))
+
+ focusListener?.({ payload: false })
+
+ expect(getPendingFailures).toHaveBeenCalledTimes(1)
+})
+
+it('leaves a failure the dialog owns out of the toasts', async () => {
+ table = [dialogFailure(1), failure(2, 'guard gave up')]
+ renderHook(() => usePendingFailures())
+ onSubscribed?.()
+
+ await waitFor(() => expect(shownDetails()).toEqual(['guard gave up']))
+})
+
+it('offers the dialog the failure it owns, and nothing else', async () => {
+ table = [failure(1, 'guard gave up'), dialogFailure(2)]
+ const { result } = renderHook(() => useDialogFailure())
+ onSubscribed?.()
+
+ await waitFor(() =>
+ expect(result.current.failure?.code).toBe('SYSPROXY_PRIVILEGE_REQUIRED'),
+ )
+})
+
+it('keeps a dismissed dialog closed while the table still holds the failure', async () => {
+ table = [dialogFailure(1)]
+ const { result } = renderHook(() => useDialogFailure())
+ onSubscribed?.()
+ await waitFor(() => expect(result.current.failure).not.toBeNull())
+
+ act(() => result.current.dismiss())
+ expect(result.current.failure).toBeNull()
+
+ focusListener?.({ payload: true })
+ await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(2))
+ expect(result.current.failure).toBeNull()
+})
+
+it('opens again when the same code fails again', async () => {
+ table = [dialogFailure(1)]
+ const { result } = renderHook(() => useDialogFailure())
+ onSubscribed?.()
+ await waitFor(() => expect(result.current.failure).not.toBeNull())
+ act(() => result.current.dismiss())
+ expect(result.current.failure).toBeNull()
+
+ table = [dialogFailure(2)]
+ onTableChanged?.()
+
+ await waitFor(() => expect(result.current.failure?.sequence).toBe(2))
+})
diff --git a/src/pages/_layout/hooks/use-pending-failures.ts b/src/pages/_layout/hooks/use-pending-failures.ts
new file mode 100644
index 000000000..90efbd5d6
--- /dev/null
+++ b/src/pages/_layout/hooks/use-pending-failures.ts
@@ -0,0 +1,127 @@
+import { getCurrentWindow } from '@tauri-apps/api/window'
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import { getPendingFailures, type PendingFailure } from '@/services/cmds'
+import { subscribeVergeEvents } from '@/services/events'
+import { showNotice } from '@/services/notice-service'
+
+/** Failures handled by the recovery dialog instead of a toast. */
+const CODES_SHOWN_AS_A_DIALOG = new Set([
+ 'SYSPROXY_PRIVILEGE_REQUIRED',
+ 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
+])
+
+/** Return whether a native window is visible and not minimized; fail closed. */
+const windowIsWatched = async () => {
+ const window = getCurrentWindow()
+ try {
+ const [visible, minimized] = await Promise.all([
+ window.isVisible(),
+ window.isMinimized(),
+ ])
+ return visible && !minimized
+ } catch (error) {
+ console.warn('[pending-failures] window state unavailable:', error)
+ return false
+ }
+}
+
+const usePendingFailureReader = (
+ onFailures: (failures: PendingFailure[]) => void,
+) => {
+ // Avoid resubscribing when the handler changes.
+ const handlerRef = useRef(onFailures)
+ handlerRef.current = onFailures
+
+ useEffect(() => {
+ const read = () => {
+ void (async () => {
+ let failures: PendingFailure[]
+ try {
+ failures = await getPendingFailures()
+ } catch (error) {
+ console.warn('[pending-failures] could not be read:', error)
+ return
+ }
+ handlerRef.current(failures)
+ })()
+ }
+
+ const unsubscribe = subscribeVergeEvents(
+ { 'verge://pending-failures-changed': read },
+ read,
+ )
+
+ const readWhenVisible = () => {
+ if (document.visibilityState === 'visible') read()
+ }
+ document.addEventListener('visibilitychange', readWhenVisible)
+
+ const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
+ if (payload) read()
+ })
+
+ return () => {
+ unsubscribe()
+ document.removeEventListener('visibilitychange', readWhenVisible)
+ void unlistenFocus.then((unlisten) => unlisten())
+ }
+ }, [])
+}
+
+/** Show each pending failure sequence once when the window can be read. */
+export const usePendingFailures = () => {
+ const shownSequencesRef = useRef(new Map())
+
+ const showNewFailures = useCallback(async (failures: PendingFailure[]) => {
+ const asToast = failures.filter(
+ (failure) => !CODES_SHOWN_AS_A_DIALOG.has(failure.code),
+ )
+ if (asToast.length === 0) return
+
+ if (!(await windowIsWatched())) return
+
+ for (const failure of asToast) {
+ const shown = shownSequencesRef.current.get(failure.code)
+ if (shown !== undefined && shown >= failure.sequence) continue
+ shownSequencesRef.current.set(failure.code, failure.sequence)
+ showNotice.error({ code: failure.code, detail: failure.detail })
+ }
+ }, [])
+
+ usePendingFailureReader(
+ useCallback(
+ (failures) => {
+ void showNewFailures(failures)
+ },
+ [showNewFailures],
+ ),
+ )
+}
+
+/** Return the oldest undismissed failure owned by the dialog. */
+export const useDialogFailure = () => {
+ const [failure, setFailure] = useState(null)
+ // Dismiss locally without retiring the backend failure.
+ const [dismissedSequence, setDismissedSequence] = useState(
+ null,
+ )
+
+ usePendingFailureReader(
+ useCallback((failures) => {
+ setFailure(
+ failures.find((entry) => CODES_SHOWN_AS_A_DIALOG.has(entry.code)) ??
+ null,
+ )
+ }, []),
+ )
+
+ const dismiss = useCallback(() => {
+ setDismissedSequence(failure?.sequence ?? null)
+ }, [failure])
+
+ const shown =
+ failure && failure.sequence !== dismissedSequence ? failure : null
+
+ return { failure: shown, dismiss }
+}
diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx
index f274255d5..cbb3ac7e7 100644
--- a/src/pages/profiles.tsx
+++ b/src/pages/profiles.tsx
@@ -61,7 +61,7 @@ import {
updateProfile,
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
-import { showNotice } from '@/services/notice-service'
+import { errorDetail, showNotice } from '@/services/notice-service'
import {
fetchCacheData,
revalidateQueries,
@@ -246,7 +246,7 @@ const ProfilePage = () => {
console.error('[紧急刷新] 失败:', error)
showNotice.error(
'profiles.page.feedback.notices.emergencyRefreshFailed',
- { message: String(error) },
+ { message: errorDetail(error) },
4000,
)
}
@@ -297,8 +297,9 @@ const ProfilePage = () => {
} catch (initialErr) {
console.warn('[订阅导入] 首次导入失败:', initialErr)
- if (String(initialErr).toLowerCase().includes('legacy tls')) {
- showNotice.error(String(initialErr))
+ const initialDetail = errorDetail(initialErr)
+ if (initialDetail.toLowerCase().includes('legacy tls')) {
+ showNotice.error(initialErr)
return
}
@@ -316,7 +317,7 @@ const ProfilePage = () => {
// 回退导入也失败
showNotice.error(
'profiles.page.feedback.notifications.importFail',
- String(retryErr),
+ retryErr,
)
}
} finally {
diff --git a/src/services/cmds.ts b/src/services/cmds.ts
index 93b25e797..6f86de535 100644
--- a/src/services/cmds.ts
+++ b/src/services/cmds.ts
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
+import type { CommandFailure } from '@/services/notice-service'
import { showNotice } from '@/services/notice-service'
import type { ProxyViewV1 } from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
@@ -205,7 +206,7 @@ export async function getEmbeddedServerPort() {
}
export async function changeClashCore(clashCore: string) {
- return invoke('change_clash_core', { clashCore })
+ return invoke('change_clash_core', { clashCore })
}
export async function restartCore() {
@@ -406,6 +407,29 @@ export const getRuntimeState = async () => {
return invoke('get_runtime_state')
}
+/** Operation associated with a pending failure. */
+export type FailedOperation =
+ | 'systemProxyEnable'
+ | 'systemProxyDisable'
+ | 'systemProxyRestore'
+ | 'systemProxyGuard'
+
+/** Latest unresolved failure for one stable code. */
+export interface PendingFailure {
+ /** Stable table key. */
+ code: string
+ /** Full diagnostic chain. */
+ detail: string
+ operation: FailedOperation
+ /** Identity for repeated failures under the same code. */
+ sequence: number
+}
+
+/** Read unresolved failures without consuming them. */
+export const getPendingFailures = async () => {
+ return invoke('get_pending_failures')
+}
+
// 获取应用运行时间
export const getAppUptime = async () => {
return invoke('get_app_uptime')
diff --git a/src/services/events.ts b/src/services/events.ts
index 59d3afdc7..2edf58500 100644
--- a/src/services/events.ts
+++ b/src/services/events.ts
@@ -25,6 +25,8 @@ interface VergeEvents {
'verge://timer-updated': string
/** How the core is running changed; carries the whole snapshot. */
'verge://run-state-changed': RunState
+ /** Pending failures changed; reread the table. */
+ 'verge://pending-failures-changed': null
/** Which profile is active changed. */
'profile-changed': string
'profile-update-started': { uid?: string }
diff --git a/src/services/notice-service.test.ts b/src/services/notice-service.test.ts
new file mode 100644
index 000000000..531ca4706
--- /dev/null
+++ b/src/services/notice-service.test.ts
@@ -0,0 +1,241 @@
+import { afterEach, describe, expect, it } from 'vitest'
+
+import {
+ boundNoticeText,
+ errorDetail,
+ getSnapshotNotices,
+ hideNotice,
+ showNotice,
+} from '@/services/notice-service'
+
+/** Return the last notice text that is actually rendered. */
+function renderedDetail(): string | undefined {
+ const notices = getSnapshotNotices()
+ const notice = notices[notices.length - 1]
+ if (typeof notice?.message === 'string') return notice.message
+ const slot = notice?.i18n?.params?.message
+ return typeof slot === 'string' ? slot : undefined
+}
+
+/** Return the rendered failure notice fields. */
+function renderedExplanation() {
+ const notices = getSnapshotNotices()
+ const i18n = notices[notices.length - 1]?.i18n
+ const readString = (value: unknown) =>
+ typeof value === 'string' ? value : undefined
+
+ return {
+ outerKey: i18n?.key,
+ explanationKey: readString(i18n?.params?.prefixKey),
+ prefix: readString(i18n?.params?.prefix),
+ detail: readString(i18n?.params?.message),
+ }
+}
+
+afterEach(() => {
+ getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
+})
+
+describe('command failures reaching a notice', () => {
+ it('keeps the detail when the failure is the message', () => {
+ showNotice.error({
+ code: 'CORE_START_FAILED',
+ detail: 'connection refused',
+ })
+
+ expect(renderedDetail()).toContain('connection refused')
+ })
+
+ it('keeps the detail when a translation key comes first', () => {
+ showNotice.error('settings.feedback.errors.clash.startFailed', {
+ code: 'CORE_START_FAILED',
+ detail: 'connection refused',
+ })
+
+ expect(renderedDetail()).toContain('connection refused')
+ })
+
+ it('keeps the detail when real translation params come first', () => {
+ showNotice.error(
+ 'settings.feedback.errors.clash.startFailed',
+ { name: 'mihomo' },
+ { code: 'CORE_START_FAILED', detail: 'connection refused' },
+ )
+
+ expect(renderedDetail()).toContain('connection refused')
+ })
+
+ it('keeps the detail for a failure that carries no code', () => {
+ showNotice.error('settings.feedback.errors.clash.startFailed', {
+ detail: 'disk full',
+ })
+
+ expect(renderedDetail()).toContain('disk full')
+ })
+})
+
+describe('system proxy failures the user can be told about', () => {
+ it('explains a fallback to a direct connection', () => {
+ showNotice.error({
+ code: 'SYSPROXY_DIRECT_FALLBACK',
+ detail: 'service could not set the proxy',
+ })
+
+ expect(renderedExplanation()).toEqual({
+ outerKey: 'shared.feedback.notices.prefixedRaw',
+ explanationKey: 'settings.feedback.errors.sysproxy.directFallback',
+ prefix: undefined,
+ detail: 'service could not set the proxy',
+ })
+ })
+
+ it('explains a guard that gave up', () => {
+ showNotice.error({
+ code: 'SYSPROXY_GUARD_STOPPED',
+ detail: 'the proxy could not be reasserted',
+ })
+
+ expect(renderedExplanation()).toEqual({
+ outerKey: 'shared.feedback.notices.prefixedRaw',
+ explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
+ prefix: undefined,
+ detail: 'the proxy could not be reasserted',
+ })
+ })
+
+ it('says nothing at all about a failure a dialog is already reporting', () => {
+ showNotice.error({
+ code: 'SYSPROXY_PRIVILEGE_REQUIRED',
+ detail: 'admin privileges required to modify system proxy',
+ })
+ showNotice.error({
+ code: 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
+ detail: 'the core is still a sidecar',
+ })
+
+ expect(getSnapshotNotices()).toEqual([])
+ })
+
+ it('explains the cause even when the caller names the operation', () => {
+ showNotice.error(
+ 'layout.components.serviceMigration.errors.restartFailed',
+ {
+ code: 'SYSPROXY_GUARD_STOPPED',
+ detail: 'the proxy could not be reasserted',
+ },
+ )
+
+ expect(renderedExplanation()).toEqual({
+ outerKey: 'shared.feedback.notices.prefixedRaw',
+ explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
+ prefix: undefined,
+ detail: 'the proxy could not be reasserted',
+ })
+ })
+
+ it('leaves the caller key in place for a code nothing explains', () => {
+ showNotice.error(
+ 'layout.components.serviceMigration.errors.restartFailed',
+ {
+ code: 'SOME_UNMAPPED_CODE',
+ detail: 'connection refused',
+ },
+ )
+
+ expect(renderedExplanation()).toEqual({
+ outerKey: 'shared.feedback.notices.prefixedRaw',
+ explanationKey: 'layout.components.serviceMigration.errors.restartFailed',
+ prefix: undefined,
+ detail: 'connection refused',
+ })
+ })
+
+ it('leaves literal caller text in place, mapped code or not', () => {
+ showNotice.error('Could not reconnect the profile', {
+ code: 'SYSPROXY_GUARD_STOPPED',
+ detail: 'the proxy could not be reasserted',
+ })
+
+ expect(renderedExplanation()).toEqual({
+ outerKey: 'shared.feedback.notices.prefixedRaw',
+ explanationKey: undefined,
+ prefix: 'Could not reconnect the profile',
+ detail: 'the proxy could not be reasserted',
+ })
+ })
+
+ it('explains a guard that gave up', () => {
+ showNotice.error({
+ code: 'SYSPROXY_GUARD_STOPPED',
+ detail: 'service refused three times running',
+ })
+
+ expect(renderedExplanation()).toEqual({
+ outerKey: 'shared.feedback.notices.prefixedRaw',
+ explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
+ prefix: undefined,
+ detail: 'service refused three times running',
+ })
+ })
+
+ it('falls back to the generic message for a code nothing maps', () => {
+ showNotice.error({ code: 'SYSPROXY_NOT_A_REAL_CODE', detail: 'boom' })
+
+ expect(renderedExplanation()).toEqual({
+ outerKey: 'shared.feedback.notices.prefixedRaw',
+ explanationKey: 'shared.feedback.errors.operationFailed',
+ prefix: undefined,
+ detail: 'boom',
+ })
+ })
+})
+
+describe('boundNoticeText', () => {
+ it('leaves text that fits exactly at the limit alone', () => {
+ const exact = 'x'.repeat(500)
+
+ expect(boundNoticeText(exact)).toBe(exact)
+ })
+
+ it('marks text it had to shorten', () => {
+ expect(boundNoticeText('x'.repeat(501))).toBe(`${'x'.repeat(500)}…`)
+ })
+
+ it('does not cut a surrogate pair in half', () => {
+ expect(boundNoticeText('🙂'.repeat(600))).toBe(`${'🙂'.repeat(500)}…`)
+ })
+
+ it('does not mistake a long message for a translation key', () => {
+ const message = `a.${'b'.repeat(400)}`
+ showNotice.error(message)
+
+ expect(renderedExplanation()).toEqual({
+ outerKey: 'shared.feedback.notices.raw',
+ explanationKey: undefined,
+ prefix: undefined,
+ detail: message,
+ })
+ })
+
+ it('does not leave a space before the ellipsis', () => {
+ expect(boundNoticeText(`${'x'.repeat(498)} yyy`)).toBe(
+ `${'x'.repeat(498)}…`,
+ )
+ })
+})
+
+describe('errorDetail', () => {
+ it('reads the detail out of a command failure', () => {
+ expect(errorDetail({ code: 'X', detail: 'legacy TLS not supported' })).toBe(
+ 'legacy TLS not supported',
+ )
+ })
+
+ it('passes plain strings through untouched', () => {
+ expect(errorDetail('something broke')).toBe('something broke')
+ })
+
+ it('still reads an Error, which not every failure path replaces', () => {
+ expect(errorDetail(new Error('boom'))).toBe('boom')
+ })
+})
diff --git a/src/services/notice-service.ts b/src/services/notice-service.ts
index 050e95069..299bd4e63 100644
--- a/src/services/notice-service.ts
+++ b/src/services/notice-service.ts
@@ -1,6 +1,9 @@
import i18n from 'i18next'
import { ReactNode, isValidElement } from 'react'
+import type { FailedOperation } from '@/services/cmds'
+import type { TranslationKey } from '@/types/generated/i18n-keys'
+
type NoticeType = 'success' | 'error' | 'info'
interface NoticeTranslationDescriptor {
@@ -14,9 +17,22 @@ interface NoticeItem {
readonly duration: number
readonly message?: ReactNode
readonly i18n?: NoticeTranslationDescriptor
+ /** Stable classified failure code. */
+ readonly code?: string
+ /** Requested operation when known. */
+ readonly operation?: FailedOperation
timerId?: ReturnType
}
+/** Failures owned by the recovery dialog. */
+const CODES_A_DIALOG_REPORTS = new Set([
+ 'SYSPROXY_PRIVILEGE_REQUIRED',
+ 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
+])
+
+export const isReportedByDialog = (code: string | undefined): boolean =>
+ code !== undefined && CODES_A_DIALOG_REPORTS.has(code)
+
type NoticeContent = unknown
type NoticeExtra = unknown
@@ -45,8 +61,8 @@ const DEFAULT_DURATIONS: Readonly> = {
}
const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/
-const CODED_ERROR_PATTERN = /^CVR_ERROR:([A-Z0-9_]+)(?:\n([\s\S]*))?$/
-const CODED_ERROR_TRANSLATION_KEYS: Readonly> = {
+/** Localized explanation for each stable failure code. */
+const CODED_ERROR_TRANSLATION_KEYS: Readonly> = {
CLASH_CONFIG_UPDATE_FAILED:
'settings.feedback.errors.clash.configUpdateFailed',
CLASH_MODE_UPDATE_FAILED: 'settings.feedback.errors.clash.modeUpdateFailed',
@@ -70,6 +86,12 @@ const CODED_ERROR_TRANSLATION_KEYS: Readonly> = {
SERVICE_SIDECAR_FAILED: 'settings.feedback.errors.clashService.sidecarFailed',
SERVICE_UNINSTALL_FAILED:
'settings.feedback.errors.clashService.uninstallFailed',
+ SYSPROXY_DIRECT_FALLBACK: 'settings.feedback.errors.sysproxy.directFallback',
+ SYSPROXY_GUARD_STOPPED: 'settings.feedback.errors.sysproxy.guardStopped',
+ SYSPROXY_SIDECAR_WHILE_SERVICE_READY:
+ 'settings.feedback.errors.sysproxy.sidecarWhileServiceReady',
+ SYSPROXY_PRIVILEGE_REQUIRED:
+ 'settings.feedback.errors.sysproxy.privilegeRequired',
}
let nextId = 0
@@ -84,6 +106,8 @@ interface ParsedNoticeExtras {
params?: Record
raw?: unknown
duration?: number
+ /** Code extracted before translation parameters are flattened. */
+ nestedCode?: string
}
function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
@@ -100,6 +124,14 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
continue
}
+ // Parse command failures before generic translation params.
+ if (isCommandFailure(extra)) {
+ if (!raw) {
+ raw = extra
+ continue
+ }
+ }
+
if (isPlainRecord(extra)) {
if (!params) {
params = extra
@@ -126,7 +158,35 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
}
}
- return { params, raw, duration }
+ return {
+ params: params && readableParams(params),
+ raw,
+ duration,
+ nestedCode: nestedFailureCode(params),
+ }
+}
+
+/** Replace structured failures in translation params with readable detail. */
+function readableParams(
+ source: Record,
+): Record {
+ return Object.fromEntries(
+ Object.entries(source).map(([key, value]) => [
+ key,
+ isCommandFailure(value) ? value.detail : value,
+ ]),
+ )
+}
+
+/** Find a classified failure nested in translation parameters. */
+function nestedFailureCode(
+ source?: Record,
+): string | undefined {
+ if (!source) return undefined
+ for (const value of Object.values(source)) {
+ if (isCommandFailure(value) && value.code) return value.code
+ }
+ return undefined
}
function resolveDuration(type: NoticeType, override?: number) {
@@ -137,7 +197,12 @@ function buildNotice(
id: number,
type: NoticeType,
duration: number,
- payload: { message?: ReactNode; i18n?: NoticeTranslationDescriptor },
+ payload: {
+ message?: ReactNode
+ i18n?: NoticeTranslationDescriptor
+ code?: string
+ operation?: FailedOperation
+ },
timerId?: ReturnType,
): NoticeItem {
return {
@@ -178,6 +243,23 @@ function isPlainRecord(value: unknown): value is Record {
return proto === Object.prototype || proto === null
}
+/** Maximum code points rendered in a notice. */
+const MAX_RENDERED_TEXT_CHARS = 500
+
+/** Bound resolved display text by code point and append an ellipsis when truncated. */
+export function boundNoticeText(text: string): string {
+ let end = 0
+ let counted = 0
+ for (const codePoint of text) {
+ if (counted === MAX_RENDERED_TEXT_CHARS) {
+ return `${text.slice(0, end).trimEnd()}…`
+ }
+ end += codePoint.length
+ counted += 1
+ }
+ return text
+}
+
function createRawDescriptor(message: string): NoticeTranslationDescriptor {
return {
key: 'shared.feedback.notices.raw',
@@ -185,8 +267,13 @@ function createRawDescriptor(message: string): NoticeTranslationDescriptor {
}
}
+/** Messages longer than any real translation key are treated as raw text. */
+const MAX_TRANSLATION_KEY_CHARS = 200
+
function isLikelyTranslationKey(key: string) {
- return TRANSLATION_KEY_PATTERN.test(key)
+ return (
+ key.length <= MAX_TRANSLATION_KEY_CHARS && TRANSLATION_KEY_PATTERN.test(key)
+ )
}
function shouldUseTranslationKey(
@@ -210,6 +297,7 @@ function extractDisplayText(input: unknown): string | undefined {
if (input instanceof Error) {
return input.message || input.name
}
+ if (isCommandFailure(input)) return input.detail
if (typeof input === 'object' && input !== null) {
const maybeMessage = (input as { message?: unknown }).message
if (typeof maybeMessage === 'string') return maybeMessage
@@ -221,16 +309,40 @@ function extractDisplayText(input: unknown): string | undefined {
}
}
-function parseCodedError(input?: string) {
- if (!input) return undefined
- const match = input.match(CODED_ERROR_PATTERN)
- if (!match) return undefined
+/** What a failed Tauri command reports. */
+export interface CommandFailure {
+ /** Stable identifier for the kind of failure, when the backend could classify it. */
+ code?: string
+ /** Requested operation when known by the command. */
+ operation?: FailedOperation
+ /** Human-readable diagnostic detail. */
+ detail: string
+}
+function isCommandFailure(input: unknown): input is CommandFailure {
+ if (typeof input !== 'object' || input === null) return false
+ const candidate = input as Partial
+ return (
+ typeof candidate.detail === 'string' &&
+ (candidate.code === undefined || typeof candidate.code === 'string')
+ )
+}
+
+/** Extract display text from a structured or legacy failure. */
+export function errorDetail(input: unknown): string {
+ return extractDisplayText(input) ?? ''
+}
+
+/** Parse a classified command failure for notice rendering. */
+function parseCommandFailure(input: unknown) {
+ if (!isCommandFailure(input) || !input.code) return undefined
+
+ const explanation = CODED_ERROR_TRANSLATION_KEYS[input.code]
return {
- translationKey:
- CODED_ERROR_TRANSLATION_KEYS[match[1]] ??
- 'shared.feedback.errors.operationFailed',
- detail: match[2]?.trim(),
+ translationKey: explanation ?? 'shared.feedback.errors.operationFailed',
+ /** Whether this code has a specific explanation. */
+ explained: explanation !== undefined,
+ detail: input.detail.trim() || undefined,
}
}
@@ -240,7 +352,7 @@ function normalizeNoticeMessage(
raw?: unknown,
): { message?: ReactNode; i18n?: NoticeTranslationDescriptor } {
const rawText = raw !== undefined ? extractDisplayText(raw) : undefined
- const parsedRawError = parseCodedError(rawText)
+ const parsedRawError = parseCommandFailure(raw)
const rawDetail = parsedRawError?.detail ?? rawText
if (isValidElement(message)) {
@@ -248,7 +360,7 @@ function normalizeNoticeMessage(
}
if (isMaybeTranslationDescriptor(message)) {
- const originalParams = message.params ?? {}
+ const originalParams = readableParams(message.params ?? {})
const mergedParams = Object.keys(params ?? {}).length
? { ...originalParams, ...params }
: { ...originalParams }
@@ -275,7 +387,7 @@ function normalizeNoticeMessage(
}
}
- const parsedMessageError = parseCodedError(extractDisplayText(message))
+ const parsedMessageError = parseCommandFailure(message)
if (parsedMessageError) {
if (!parsedMessageError.detail) {
return {
@@ -298,12 +410,17 @@ function normalizeNoticeMessage(
if (typeof message === 'string') {
if (rawDetail !== undefined) {
if (shouldUseTranslationKey(message, params)) {
+ // A mapped cause outranks a translation key that only names the operation.
+ const prefixKey = parsedRawError?.explained
+ ? parsedRawError.translationKey
+ : message
+
return {
i18n: {
key: 'shared.feedback.notices.prefixedRaw',
params: {
...(params ?? {}),
- prefixKey: message,
+ prefixKey,
message: rawDetail,
},
},
@@ -351,7 +468,12 @@ const baseShowNotice = (
...extras: NoticeExtra[]
): number => {
const id = nextId++
- const { params, raw, duration } = parseNoticeExtras(extras)
+ const { params, raw, duration, nestedCode } = parseNoticeExtras(extras)
+ // Accept codes from raw, direct, or interpolated failures.
+ const code = failureCode(raw) ?? failureCode(message) ?? nestedCode
+ const operation = failureOperation(raw) ?? failureOperation(message)
+ // Suppress duplicate toasts while preserving the caller's id contract.
+ if (isReportedByDialog(code)) return id
const effectiveDuration = resolveDuration(type, duration)
const timerId =
effectiveDuration > 0
@@ -363,15 +485,33 @@ const baseShowNotice = (
id,
type,
effectiveDuration,
- normalizedMessage,
+ { ...normalizedMessage, code, operation },
timerId,
)
+ // Replace older notices for the same failure code.
+ if (code) {
+ notices
+ .filter((existing) => existing.code === code)
+ .forEach((existing) => {
+ if (existing.timerId) clearTimeout(existing.timerId)
+ })
+ notices = notices.filter((existing) => existing.code !== code)
+ }
+
notices = [...notices, notice]
notifySubscribers()
return id
}
+function failureCode(input: unknown): string | undefined {
+ return isCommandFailure(input) ? input.code : undefined
+}
+
+function failureOperation(input: unknown): FailedOperation | undefined {
+ return isCommandFailure(input) ? input.operation : undefined
+}
+
/**
* Shows a global notice; `showNotice.success / error / info` are the usual entry points.
*
diff --git a/src/services/service-request.ts b/src/services/service-request.ts
new file mode 100644
index 000000000..46c7436a2
--- /dev/null
+++ b/src/services/service-request.ts
@@ -0,0 +1,40 @@
+/** Why a privileged service is needed. */
+export type ServiceRequestReason =
+ | 'sysproxyRefused'
+ | 'sysproxySidecarReady'
+ | 'tunNeedsService'
+
+export interface ServiceRequest {
+ readonly reason: ServiceRequestReason
+ /** Settings to apply after the core enters service mode. */
+ readonly restore?: Partial
+}
+
+type Subscriber = () => void
+
+let request: ServiceRequest | null = null
+const subscribers = new Set()
+
+const notify = () => {
+ subscribers.forEach((subscriber) => subscriber())
+}
+
+export const requestService = (next: ServiceRequest) => {
+ request = next
+ notify()
+}
+
+export const clearServiceRequest = () => {
+ if (request === null) return
+ request = null
+ notify()
+}
+
+export const subscribeServiceRequest = (subscriber: Subscriber) => {
+ subscribers.add(subscriber)
+ return () => {
+ subscribers.delete(subscriber)
+ }
+}
+
+export const getServiceRequest = () => request
diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts
index 222a02d89..99a6dbb8a 100644
--- a/src/types/generated/i18n-keys.ts
+++ b/src/types/generated/i18n-keys.ts
@@ -138,6 +138,16 @@ export const translationKeys = [
'layout.components.serviceMigration.errors.restartFailed',
'layout.components.serviceMigration.errors.revalidationFailed',
'layout.components.serviceMigration.errors.sidecarFailed',
+ 'layout.components.sysproxyPrivilege.title',
+ 'layout.components.sysproxyPrivilege.message',
+ 'layout.components.sysproxyPrivilege.serviceReadyMessage',
+ 'layout.components.sysproxyPrivilege.alternative',
+ 'layout.components.sysproxyPrivilege.later',
+ 'layout.components.sysproxyPrivilege.installing',
+ 'layout.components.sysproxyPrivilege.restarting',
+ 'layout.components.sysproxyPrivilege.applying',
+ 'layout.components.sysproxyPrivilege.tunTitle',
+ 'layout.components.sysproxyPrivilege.tunMessage',
'layout.feedback.errors.restartCoreStopFailed',
'layout.feedback.errors.quitCoreStopFailed',
'logs.page.title',
@@ -397,8 +407,12 @@ export const translationKeys = [
'settings.sections.proxyControl.tooltips.tunUnavailable',
'settings.sections.proxyControl.actions.installService',
'settings.sections.proxyControl.actions.uninstallService',
+ 'settings.sections.proxyControl.actions.switchToServiceMode',
'settings.sections.proxyControl.fields.systemProxy',
'settings.sections.proxyControl.fields.tunMode',
+ 'settings.sections.proxyControl.messages.installedCheckProxy',
+ 'settings.sections.proxyControl.messages.installedCoreNotOnService',
+ 'settings.sections.proxyControl.messages.installedProxyRestored',
'settings.sections.externalController.title',
'settings.sections.externalController.fields.enable',
'settings.sections.externalController.fields.address',
@@ -760,6 +774,10 @@ export const translationKeys = [
'settings.feedback.errors.clashService.reinstallFailed',
'settings.feedback.errors.clashService.repairFailed',
'settings.feedback.errors.clashService.sidecarFailed',
+ 'settings.feedback.errors.sysproxy.privilegeRequired',
+ 'settings.feedback.errors.sysproxy.directFallback',
+ 'settings.feedback.errors.sysproxy.guardStopped',
+ 'settings.feedback.errors.sysproxy.sidecarWhileServiceReady',
'settings.feedback.notifications.clash.restartSuccess',
'settings.feedback.notifications.clash.versionUpdated',
'settings.feedback.notifications.clash.alreadyLatestVersion',
diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts
index d505d7cae..4b8858324 100644
--- a/src/types/generated/i18n-resources.ts
+++ b/src/types/generated/i18n-resources.ts
@@ -231,6 +231,18 @@ export interface TranslationResources {
title: string
unavailableMessage: string
}
+ sysproxyPrivilege: {
+ alternative: string
+ applying: string
+ installing: string
+ later: string
+ message: string
+ restarting: string
+ serviceReadyMessage: string
+ title: string
+ tunMessage: string
+ tunTitle: string
+ }
}
feedback: {
errors: {
@@ -796,6 +808,12 @@ export interface TranslationResources {
sidecarFailed: string
uninstallFailed: string
}
+ sysproxy: {
+ directFallback: string
+ guardStopped: string
+ privilegeRequired: string
+ sidecarWhileServiceReady: string
+ }
}
notifications: {
clash: {
@@ -1297,12 +1315,18 @@ export interface TranslationResources {
proxyControl: {
actions: {
installService: string
+ switchToServiceMode: string
uninstallService: string
}
fields: {
systemProxy: string
tunMode: string
}
+ messages: {
+ installedCheckProxy: string
+ installedCoreNotOnService: string
+ installedProxyRestored: string
+ }
tooltips: {
systemProxy: string
tunMode: string