cc-haha/desktop/src/stores/settingsStore.ts
程序员阿江(Relakkes) 718e80f8c5 fix(desktop): harden portable storage writes (#949)
Guard best-effort Electron window-state persistence so an unwritable data directory cannot crash the main process. Validate portable data directories before app-mode persistence and surface failures back to settings so restart is not attempted after rollback.

Tested: cd desktop && bun test electron/services/windows.test.ts electron/services/appMode.test.ts
Tested: cd desktop && bun run test src/stores/settingsStore.test.ts -t "settingsStore app mode" --run
Tested: cd desktop && bun run check:electron
Tested: bun run check:desktop
Not-tested: bun run check:native; bun run verify; coverage gates not run for scoped local handoff.
Confidence: high
Scope-risk: narrow
2026-07-03 18:23:35 +08:00

786 lines
24 KiB
TypeScript

import { create } from 'zustand'
import { ApiError } from '../api/client'
import { settingsApi } from '../api/settings'
import { modelsApi } from '../api/models'
import { h5AccessApi } from '../api/h5Access'
import { tracesApi } from '../api/traces'
import {
isThemeMode,
type AppMode,
type AppModeConfig,
type ChatSendBehavior,
type DesktopTerminalSettings,
type DesktopTerminalStartupShell,
type H5AccessDiagnostics,
type H5AccessSettings,
type NetworkSettings,
type OutputStyleOption,
type OutputStylesResponse,
type PermissionMode,
type EffortLevel,
type ModelInfo,
type ThemeMode,
type UpdateProxyMode,
type UpdateProxySettings,
type WebSearchSettings,
} from '../types/settings'
import type { TraceCaptureSettings } from '../types/trace'
import { getDesktopHost } from '../lib/desktopHost'
import type { Locale } from '../i18n'
import {
APP_ZOOM_CONTROL_STEP,
DEFAULT_APP_ZOOM,
MAX_APP_ZOOM,
MIN_APP_ZOOM,
applyAppZoomLevel,
normalizeAppZoomLevel,
readStoredAppZoomLevel,
} from '../lib/appZoom'
import { useUIStore } from './uiStore'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
export const UI_ZOOM_MIN = MIN_APP_ZOOM
export const UI_ZOOM_MAX = MAX_APP_ZOOM
export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP
export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM
let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve()
const VALID_LOCALES: readonly Locale[] = ['en', 'zh', 'zh-TW', 'jp', 'kr']
function getStoredLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
if (stored && (VALID_LOCALES as readonly string[]).includes(stored)) return stored as Locale
} catch { /* localStorage unavailable */ }
return 'zh'
}
type SettingsStore = {
permissionMode: PermissionMode
currentModel: ModelInfo | null
effortLevel: EffortLevel
thinkingEnabled: boolean
autoDreamEnabled: boolean
availableModels: ModelInfo[]
activeProviderName: string | null
locale: Locale
theme: ThemeMode
chatSendBehavior: ChatSendBehavior
outputStyle: string
outputStyles: OutputStyleOption[]
outputStyleScope: OutputStylesResponse['scope']
outputStyleWorkDir: string | null
outputStylesLoading: boolean
outputStyleError: string | null
skipWebFetchPreflight: boolean
desktopNotificationsEnabled: boolean
desktopTerminal: DesktopTerminalSettings
webSearch: WebSearchSettings
updateProxy: UpdateProxySettings
network: NetworkSettings
traceCapture: TraceCaptureSettings
h5Access: H5AccessSettings
h5AccessDiagnostics: H5AccessDiagnostics | null
h5AccessError: string | null
responseLanguage: string
uiZoom: number
isLoading: boolean
error: string | null
appMode: AppModeConfig
appModeRequiresRestart: boolean
fetchAll: () => Promise<void>
fetchH5Access: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
setModel: (modelId: string) => Promise<void>
setEffort: (level: EffortLevel) => Promise<void>
setThinkingEnabled: (enabled: boolean) => Promise<void>
setAutoDreamEnabled: (enabled: boolean) => Promise<void>
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
setChatSendBehavior: (behavior: ChatSendBehavior) => Promise<void>
fetchOutputStyles: (workDir?: string | null) => Promise<void>
setOutputStyle: (outputStyle: string, workDir?: string | null) => Promise<void>
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
setWebSearch: (settings: WebSearchSettings) => Promise<void>
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
setNetwork: (settings: NetworkSettings) => Promise<void>
setTraceCaptureEnabled: (enabled: boolean) => Promise<void>
enableH5Access: () => Promise<string>
disableH5Access: () => Promise<void>
regenerateH5AccessToken: () => Promise<string>
updateH5AccessSettings: (input: {
allowedOrigins?: string[]
publicBaseUrl?: string | null
fixedPort?: number | null
disconnectGraceSeconds?: number | null
}) => Promise<void>
setResponseLanguage: (language: string) => Promise<void>
fetchAppMode: () => Promise<void>
setAppMode: (mode: AppMode, portableDir?: string | null) => Promise<void>
setUiZoom: (zoom: number) => void
}
type NetworkSettingsInput = Partial<Omit<NetworkSettings, 'proxy'>> & {
proxy?: Partial<NetworkSettings['proxy']>
}
const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = {
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
}
const DEFAULT_DESKTOP_TERMINAL_SETTINGS: DesktopTerminalSettings = {
startupShell: 'system',
customShellPath: '',
}
const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = {
mode: 'system',
url: '',
}
const DEFAULT_NETWORK_SETTINGS: NetworkSettings = {
aiRequestTimeoutMs: 600_000,
proxy: {
mode: 'direct',
url: '',
},
}
const DEFAULT_OUTPUT_STYLE = 'default'
const DEFAULT_OUTPUT_STYLE_OPTIONS: OutputStyleOption[] = [
{
value: DEFAULT_OUTPUT_STYLE,
label: 'Default',
description: 'Claude completes coding tasks efficiently and provides concise responses',
source: 'built-in',
},
]
const DEFAULT_TRACE_CAPTURE_SETTINGS: TraceCaptureSettings = {
enabled: true,
storageDir: '',
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
effortLevel: 'max',
thinkingEnabled: true,
autoDreamEnabled: false,
availableModels: [],
activeProviderName: null,
locale: getStoredLocale(),
theme: useUIStore.getState().theme,
chatSendBehavior: 'enter',
outputStyle: DEFAULT_OUTPUT_STYLE,
outputStyles: DEFAULT_OUTPUT_STYLE_OPTIONS,
outputStyleScope: 'userSettings',
outputStyleWorkDir: null,
outputStylesLoading: false,
outputStyleError: null,
skipWebFetchPreflight: true,
desktopNotificationsEnabled: false,
desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS,
network: DEFAULT_NETWORK_SETTINGS,
traceCapture: DEFAULT_TRACE_CAPTURE_SETTINGS,
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
h5AccessDiagnostics: null,
h5AccessError: null,
responseLanguage: '',
uiZoom: readStoredAppZoomLevel(),
isLoading: false,
error: null,
appMode: {
mode: 'default',
portableDir: null,
defaultPortableDir: null,
activeConfigDir: null,
configDirSource: 'system',
},
appModeRequiresRestart: false,
setUiZoom: (zoom: number) => {
const level = normalizeAppZoomLevel(zoom)
set({ uiZoom: level })
void applyAppZoomLevel(level)
},
fetchAll: async () => {
set({ isLoading: true, error: null })
try {
const previousH5Access = get().h5Access
const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult, traceCapture] = await Promise.all([
settingsApi.getPermissionMode(),
modelsApi.list(),
modelsApi.getCurrent(),
modelsApi.getEffort(),
settingsApi.getUser(),
loadH5AccessSettings(previousH5Access),
loadTraceCaptureSettings(),
])
const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white'
useUIStore.getState().setTheme(theme)
set({
permissionMode: mode,
availableModels: modelsRes.models,
activeProviderName: modelsRes.provider?.name ?? null,
currentModel: model,
effortLevel: level,
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
autoDreamEnabled: userSettings.autoDreamEnabled === true,
theme,
chatSendBehavior: normalizeChatSendBehavior(userSettings.chatSendBehavior),
outputStyle: normalizeOutputStyle(userSettings.outputStyle),
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true,
desktopTerminal: normalizeDesktopTerminalSettings(userSettings.desktopTerminal),
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy),
network: normalizeNetworkSettings(userSettings.network),
traceCapture,
h5Access: h5AccessResult.settings,
h5AccessDiagnostics: h5AccessResult.diagnostics,
h5AccessError: h5AccessResult.error,
responseLanguage: typeof userSettings.language === 'string' ? userSettings.language : '',
isLoading: false,
error: null,
})
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to load desktop settings'
set({ isLoading: false, error: message })
throw error
}
},
fetchH5Access: async () => {
const result = await loadH5AccessSettings(get().h5Access)
set({
h5Access: result.settings,
h5AccessDiagnostics: result.diagnostics,
h5AccessError: result.error,
})
},
setPermissionMode: async (mode) => {
const prev = get().permissionMode
set({ permissionMode: mode })
try {
await settingsApi.setPermissionMode(mode)
} catch {
set({ permissionMode: prev })
}
},
setModel: async (modelId) => {
await modelsApi.setCurrent(modelId)
const { model } = await modelsApi.getCurrent()
set({ currentModel: model })
},
setEffort: async (level) => {
const prev = get().effortLevel
set({ effortLevel: level })
try {
await modelsApi.setEffort(level)
} catch {
set({ effortLevel: prev })
}
},
setThinkingEnabled: async (enabled) => {
const prev = get().thinkingEnabled
set({ thinkingEnabled: enabled })
try {
await settingsApi.updateUser({ alwaysThinkingEnabled: enabled })
} catch {
set({ thinkingEnabled: prev })
}
},
setAutoDreamEnabled: async (enabled) => {
const prev = get().autoDreamEnabled
set({ autoDreamEnabled: enabled })
try {
await settingsApi.updateUser({ autoDreamEnabled: enabled })
} catch (error) {
set({ autoDreamEnabled: prev })
throw error
}
},
setLocale: (locale) => {
set({ locale })
try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ }
},
setTheme: async (theme) => {
const prev = get().theme
set({ theme })
useUIStore.getState().setTheme(theme)
try {
await settingsApi.updateUser({ theme })
} catch {
set({ theme: prev })
useUIStore.getState().setTheme(prev)
}
},
setChatSendBehavior: async (behavior) => {
const prev = get().chatSendBehavior
const next = normalizeChatSendBehavior(behavior)
set({ chatSendBehavior: next })
try {
await settingsApi.updateUser({ chatSendBehavior: next })
} catch (error) {
set({ chatSendBehavior: prev })
throw error
}
},
fetchOutputStyles: async (workDir) => {
set({ outputStylesLoading: true, outputStyleError: null })
try {
const response = await settingsApi.getOutputStyles(workDir)
set({
outputStyle: normalizeOutputStyle(response.outputStyle),
outputStyles: normalizeOutputStyleOptions(response.styles),
outputStyleScope: response.scope,
outputStyleWorkDir: response.workDir,
outputStylesLoading: false,
outputStyleError: null,
})
} catch (error) {
set({
outputStylesLoading: false,
outputStyleError: getErrorMessage(error, 'Failed to load output styles.'),
})
throw error
}
},
setOutputStyle: async (outputStyle, workDir) => {
const prev = {
outputStyle: get().outputStyle,
outputStyleScope: get().outputStyleScope,
outputStyleWorkDir: get().outputStyleWorkDir,
outputStyleError: get().outputStyleError,
}
set({
outputStyle,
outputStyleError: null,
})
try {
const result = await settingsApi.setOutputStyle(outputStyle, workDir)
set({
outputStyle: normalizeOutputStyle(result.outputStyle),
outputStyleScope: result.scope,
outputStyleWorkDir: result.workDir,
outputStyleError: null,
})
} catch (error) {
set({
outputStyle: prev.outputStyle,
outputStyleScope: prev.outputStyleScope,
outputStyleWorkDir: prev.outputStyleWorkDir,
outputStyleError: getErrorMessage(error, 'Failed to save output style.'),
})
throw error
}
},
setSkipWebFetchPreflight: async (enabled) => {
const prev = get().skipWebFetchPreflight
set({ skipWebFetchPreflight: enabled })
try {
await settingsApi.updateUser({ skipWebFetchPreflight: enabled })
} catch {
set({ skipWebFetchPreflight: prev })
}
},
setDesktopNotificationsEnabled: async (enabled) => {
const prev = get().desktopNotificationsEnabled
set({ desktopNotificationsEnabled: enabled })
const save = desktopNotificationsSaveQueue
.catch(() => undefined)
.then(async () => {
if (get().desktopNotificationsEnabled !== enabled) return
await settingsApi.updateUser({ desktopNotificationsEnabled: enabled })
})
desktopNotificationsSaveQueue = save
try {
await save
} catch {
if (get().desktopNotificationsEnabled === enabled) {
set({ desktopNotificationsEnabled: prev })
}
}
},
setDesktopTerminal: async (settings) => {
const prev = get().desktopTerminal
const next = normalizeDesktopTerminalSettings(settings)
set({ desktopTerminal: next })
try {
await settingsApi.updateUser({ desktopTerminal: next })
} catch (error) {
set({ desktopTerminal: prev })
throw error
}
},
setWebSearch: async (webSearch) => {
const prev = get().webSearch
const next = normalizeWebSearchSettings(webSearch)
set({ webSearch: next })
try {
await settingsApi.updateUser({ webSearch: next })
} catch {
set({ webSearch: prev })
}
},
setUpdateProxy: async (settings) => {
const prev = get().updateProxy
const next = normalizeUpdateProxySettings(settings)
set({ updateProxy: next })
try {
await settingsApi.updateUser({ updateProxy: next })
} catch (error) {
set({ updateProxy: prev })
throw error
}
},
setNetwork: async (settings) => {
const prev = get().network
const next = normalizeNetworkSettings(settings)
set({ network: next })
try {
await settingsApi.updateUser({ network: next })
} catch (error) {
set({ network: prev })
throw error
}
},
setTraceCaptureEnabled: async (enabled) => {
const prev = get().traceCapture
set({ traceCapture: { ...prev, enabled } })
try {
const next = await tracesApi.updateSettings({ enabled })
set({ traceCapture: normalizeTraceCaptureSettings(next) })
} catch (error) {
set({ traceCapture: prev })
throw error
}
},
enableH5Access: async () => {
set({ h5AccessError: null })
try {
const { settings, token } = await h5AccessApi.enable()
set({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
await refreshH5DiagnosticsSilent(set)
return token
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to enable H5 access.') })
throw error
}
},
disableH5Access: async () => {
set({ h5AccessError: null })
try {
const { settings } = await h5AccessApi.disable()
set({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
await refreshH5DiagnosticsSilent(set)
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to disable H5 access.') })
throw error
}
},
regenerateH5AccessToken: async () => {
set({ h5AccessError: null })
try {
const { settings, token } = await h5AccessApi.regenerate()
set({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
await refreshH5DiagnosticsSilent(set)
return token
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to regenerate the H5 token.') })
throw error
}
},
updateH5AccessSettings: async (input) => {
set({ h5AccessError: null })
try {
const { settings } = await h5AccessApi.update(input)
set({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
await refreshH5DiagnosticsSilent(set)
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to update H5 access settings.') })
throw error
}
},
setResponseLanguage: async (language) => {
const prev = get().responseLanguage
set({ responseLanguage: language })
try {
await settingsApi.updateUser({ language: language || undefined })
} catch {
set({ responseLanguage: prev })
}
},
fetchAppMode: async () => {
const host = getDesktopHost()
if (!host.isDesktop) return
try {
const result: AppModeConfig = await host.appMode.get()
set({ appMode: result })
} catch { /* silently ignore - not in Tauri or command unavailable */ }
},
setAppMode: async (mode, portableDir) => {
const host = getDesktopHost()
if (!host.isDesktop) return
const prev = get().appMode
const newMode: AppModeConfig = {
...prev,
mode,
portableDir: mode === 'portable'
? portableDir ?? prev.defaultPortableDir ?? prev.portableDir
: null,
activeConfigDir: mode === 'portable'
? portableDir ?? prev.defaultPortableDir ?? prev.portableDir
: null,
configDirSource: mode === 'portable' ? 'portable' : 'system',
}
set({ appMode: newMode, appModeRequiresRestart: true })
try {
await host.appMode.set({
mode,
portableDir: newMode.portableDir || null,
})
} catch (error) {
set({ appMode: prev, appModeRequiresRestart: false })
throw error
}
},
}))
function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings {
return {
mode: settings?.mode ?? 'auto',
tavilyApiKey: settings?.tavilyApiKey ?? '',
braveApiKey: settings?.braveApiKey ?? '',
}
}
function normalizeChatSendBehavior(value: unknown): ChatSendBehavior {
return value === 'modifierEnter' ? 'modifierEnter' : 'enter'
}
function normalizeOutputStyle(value: unknown): string {
return typeof value === 'string' && value.trim().length > 0
? value
: DEFAULT_OUTPUT_STYLE
}
function normalizeOutputStyleOptions(styles: OutputStyleOption[] | undefined): OutputStyleOption[] {
if (!Array.isArray(styles) || styles.length === 0) return DEFAULT_OUTPUT_STYLE_OPTIONS
const normalized = styles
.filter((style): style is OutputStyleOption =>
typeof style?.value === 'string' &&
style.value.trim().length > 0 &&
typeof style.label === 'string' &&
typeof style.description === 'string',
)
.map(style => ({
...style,
value: style.value.trim(),
label: style.label.trim() || style.value.trim(),
description: style.description.trim(),
}))
return normalized.length > 0 ? normalized : DEFAULT_OUTPUT_STYLE_OPTIONS
}
function isUpdateProxyMode(value: unknown): value is UpdateProxyMode {
return value === 'system' || value === 'manual'
}
function normalizeUpdateProxySettings(
settings: Partial<UpdateProxySettings> | undefined,
): UpdateProxySettings {
const mode = isUpdateProxyMode(settings?.mode)
? settings.mode
: DEFAULT_UPDATE_PROXY_SETTINGS.mode
return {
mode,
url: typeof settings?.url === 'string' ? settings.url.trim() : '',
}
}
function normalizeNetworkSettings(
settings: NetworkSettingsInput | undefined,
): NetworkSettings {
const timeout = typeof settings?.aiRequestTimeoutMs === 'number' && Number.isFinite(settings.aiRequestTimeoutMs)
? Math.min(Math.max(Math.round(settings.aiRequestTimeoutMs), 30_000), 1_800_000)
: DEFAULT_NETWORK_SETTINGS.aiRequestTimeoutMs
const proxyMode = settings?.proxy?.mode === 'manual'
? 'manual'
: settings?.proxy?.mode === 'system'
? 'system'
: 'direct'
return {
aiRequestTimeoutMs: timeout,
proxy: {
mode: proxyMode,
url: proxyMode === 'manual' && typeof settings?.proxy?.url === 'string'
? settings.proxy.url.trim()
: '',
},
}
}
function normalizeTraceCaptureSettings(
settings: TraceCaptureSettings | undefined,
): TraceCaptureSettings {
return {
enabled: settings?.enabled !== false,
storageDir: typeof settings?.storageDir === 'string' ? settings.storageDir : '',
}
}
function normalizeDesktopTerminalSettings(
settings: Partial<DesktopTerminalSettings> | undefined,
): DesktopTerminalSettings {
const startupShell = isDesktopTerminalStartupShell(settings?.startupShell)
? settings.startupShell
: DEFAULT_DESKTOP_TERMINAL_SETTINGS.startupShell
return {
startupShell,
customShellPath: typeof settings?.customShellPath === 'string'
? settings.customShellPath
: DEFAULT_DESKTOP_TERMINAL_SETTINGS.customShellPath,
}
}
function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5AccessSettings {
return {
enabled: settings?.enabled === true,
token: typeof settings?.token === 'string' && settings.token ? settings.token : null,
tokenPreview: settings?.tokenPreview ?? null,
allowedOrigins: Array.isArray(settings?.allowedOrigins) ? settings.allowedOrigins : [],
publicBaseUrl: settings?.publicBaseUrl ?? null,
fixedPort: typeof settings?.fixedPort === 'number' ? settings.fixedPort : null,
disconnectGraceSeconds: typeof settings?.disconnectGraceSeconds === 'number' ? settings.disconnectGraceSeconds : null,
}
}
async function loadTraceCaptureSettings(): Promise<TraceCaptureSettings> {
try {
return normalizeTraceCaptureSettings(await tracesApi.getSettings())
} catch {
return DEFAULT_TRACE_CAPTURE_SETTINGS
}
}
async function refreshH5DiagnosticsSilent(
set: (partial: Partial<SettingsStore>) => void,
): Promise<void> {
// Best-effort diagnostics refresh. Failure here must not surface as an
// error on the main H5 action (enable/disable/regenerate/update), because
// the main action has already succeeded by the time we reach this point.
try {
const response = await h5AccessApi.get()
const diagnostics = response?.diagnostics ?? null
set({ h5AccessDiagnostics: diagnostics })
} catch {
// silent: keep previous diagnostics value
}
}
async function loadH5AccessSettings(previousH5Access: H5AccessSettings): Promise<{
settings: H5AccessSettings
diagnostics: H5AccessDiagnostics | null
error: string | null
}> {
try {
const { settings, diagnostics } = await h5AccessApi.get()
return {
settings: normalizeH5AccessSettings(settings),
diagnostics: diagnostics ?? null,
error: null,
}
} catch (error) {
if (isLegacyH5EndpointError(error)) {
return {
settings: DEFAULT_H5_ACCESS_SETTINGS,
diagnostics: null,
error: null,
}
}
return {
settings: previousH5Access,
diagnostics: null,
error: getErrorMessage(error, 'Failed to load H5 access settings.'),
}
}
}
function isLegacyH5EndpointError(error: unknown) {
const status = error instanceof ApiError
? error.status
: typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number'
? error.status
: null
return status === 404 || status === 405
}
function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback
}
function isDesktopTerminalStartupShell(value: unknown): value is DesktopTerminalStartupShell {
return value === 'system'
|| value === 'pwsh'
|| value === 'powershell'
|| value === 'cmd'
|| value === 'custom'
}