cc-haha/desktop/electron/ipc/capabilities.ts
程序员阿江(Relakkes) 5b891151ae feat(desktop): follow the system dark/light appearance (#1106)
An Auto-dark-mode user reported being flashed by a white window every
evening, then switching to a dark palette by hand. That is two defects,
and only one of them is the missing feature.

The flash fired even for someone who had already saved a dark palette.
`index.html` hardcoded `data-theme="white"` while the code that reads the
stored theme, `initializeTheme()`, only runs after the app bundle's
dynamic imports resolve. So every launch painted white first. A
synchronous inline script now resolves the theme before any stylesheet is
parsed, and Electron seeds `backgroundColor` from a cached appearance so
the window is not white before the renderer's first frame either.

Following the system is a switch in Settings -> General rather than a
seventh palette. The OS only reports dark/light while the app ships six
palettes, so each ground carries its own preference: the picker splits
into "use in light mode" (the four paper grounds) and "use in dark mode"
(the two ink ones), and a pick lands in the preference for its own ground.
Choosing ink-blue at noon is therefore remembered for that night rather
than fighting the OS. Detection goes through `prefers-color-scheme`
because the same renderer runs under Electron, the Tauri shell and the
browser entry, and the media query is the only signal all three share.
New installs follow the system; existing ones keep their fixed palette
until they opt in, so an update never silently repaints someone's app.

`nativeTheme.themeSource` is deliberately left alone, which is the part
most likely to be "fixed" later. Pinning it to the user's palette would
make context menus and the macOS frame agree with the app, but it is a
process-wide override of `prefers-color-scheme` — the very signal this
feature reads. Re-enabling the switch would then resolve against the
pinned value instead of the real OS setting, and the override also leaks
into the preview WebContentsView, forcing third-party pages to the app's
theme. A test fails on any assignment to it.

The OS-flip listener reads the preferences from storage rather than from
its own store. The pet and trace windows run the same bootstrap with
their own store instance over one shared localStorage, so after the main
window turns the switch off their in-memory copy still says "on" — acting
on it wrote the user's choice straight back out. A `storage` listener
catches the other windows up.

`settingsStore.theme` is gone. It was a copy that only refreshed on an
explicit `setTheme`, so an OS flip left the Settings picker highlighting
a palette that was no longer on screen. uiStore owns the theme; the copy
had no remaining readers.

The 「纸·墨·印」 rename reaches the new keys too: `light` -> `warm-classic`
now migrates for the per-ground preferences, not just the applied theme,
so the palette daytime returns to is not silently reset.

Guards, each verified by breaking what it protects: the inline script is
extracted from `index.html` and run verbatim against `resolveAppliedTheme`
over every stored combination — including dirty values, which are
reachable because it runs before the persistence migrations, and
cross-ground values like a dark palette stored as the light preference;
the three copies of the palette grounds (CSS `--cc-bg`, `index.html`,
main process) are pinned to each other and to `THEME_MODES`, so a seventh
palette cannot ship without a pre-paint color; the two grounds are proven
to cover every palette at compile time; and the IPC payload is held to a
literal 6-digit hex because `setBackgroundColor` also accepts
`#AARRGGBB`, where a translucent window means click-through and overlay
spoofing.
2026-07-27 02:15:37 +08:00

267 lines
11 KiB
TypeScript

import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './channels'
type Validator = (payload: unknown) => boolean
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const noPayload: Validator = value => value === undefined
const optionalRecord: Validator = value => value === undefined || isRecord(value)
const stringPayload: Validator = value => typeof value === 'string'
const booleanPayload: Validator = value => typeof value === 'boolean'
const hasOnlyKeys = (value: Record<string, unknown>, allowedKeys: string[]) =>
Object.keys(value).every(key => allowedKeys.includes(key))
const MAX_TERMINAL_DIMENSION = 1_000
const MAX_TERMINAL_CWD_LENGTH = 4_096
const MAX_TERMINAL_WRITE_LENGTH = 1_048_576
const isTerminalSessionId = (value: unknown) =>
typeof value === 'number'
&& Number.isSafeInteger(value)
&& value > 0
const isTerminalDimension = (value: unknown) =>
typeof value === 'number'
&& Number.isInteger(value)
&& value > 0
&& value <= MAX_TERMINAL_DIMENSION
const sessionIdPayload: Validator = value =>
typeof value === 'string'
&& value.length > 0
&& value.length <= 200
&& /^[A-Za-z0-9._:-]+$/.test(value)
const isSafeUiLabel = (value: unknown) =>
typeof value === 'string'
&& value.trim().length > 0
&& value.length <= 120
&& !/[\u0000-\u001f\u007f-\u009f]/.test(value)
const petCreateFromAtlas: Validator = value => {
if (!isRecord(value) || !hasOnlyKeys(value, [
'slug',
'displayName',
'description',
'dialogTitle',
'dialogFilterName',
])) return false
if (
typeof value.slug !== 'string'
|| value.slug.length === 0
|| value.slug.length > 73
|| !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.slug)
) return false
return typeof value.displayName === 'string'
&& value.displayName.trim().length > 0
&& value.displayName.length <= 80
&& typeof value.description === 'string'
&& value.description.trim().length > 0
&& value.description.length <= 500
&& (value.dialogTitle === undefined || isSafeUiLabel(value.dialogTitle))
&& (value.dialogFilterName === undefined || isSafeUiLabel(value.dialogFilterName))
}
const petContextMenu: Validator = value => {
if (!isRecord(value) || !hasOnlyKeys(value, ['closeLabel'])) return false
if (typeof value.closeLabel !== 'string' || value.closeLabel.length > 80) return false
const closeLabel = value.closeLabel.trim()
return closeLabel.length > 0
&& closeLabel.length <= 80
&& !/[\u0000-\u001f\u007f-\u009f]/.test(value.closeLabel)
}
const petWindowDrag: Validator = value => {
if (!isRecord(value) || !hasOnlyKeys(value, ['phase', 'x', 'y'])) return false
if (value.phase !== 'start' && value.phase !== 'move' && value.phase !== 'end') return false
return ['x', 'y'].every((key) =>
typeof value[key] === 'number'
&& Number.isFinite(value[key])
&& Math.abs(value[key]) <= 1_000_000)
}
const commandInvoke: Validator = value =>
isRecord(value)
&& typeof value.command === 'string'
&& value.command.length > 0
&& (value.args === undefined || isRecord(value.args))
const terminalWrite: Validator = value =>
isRecord(value)
&& hasOnlyKeys(value, ['sessionId', 'data'])
&& isTerminalSessionId(value.sessionId)
&& typeof value.data === 'string'
&& value.data.length <= MAX_TERMINAL_WRITE_LENGTH
const terminalSpawn: Validator = value =>
value === undefined
|| (
isRecord(value)
&& hasOnlyKeys(value, ['cols', 'rows', 'cwd'])
&& (value.cols === undefined || isTerminalDimension(value.cols))
&& (value.rows === undefined || isTerminalDimension(value.rows))
&& (
value.cwd === undefined
|| (
typeof value.cwd === 'string'
&& value.cwd.length <= MAX_TERMINAL_CWD_LENGTH
&& !value.cwd.includes('\0')
)
)
)
const terminalResize: Validator = value =>
isRecord(value)
&& hasOnlyKeys(value, ['sessionId', 'cols', 'rows'])
&& isTerminalSessionId(value.sessionId)
&& isTerminalDimension(value.cols)
&& isTerminalDimension(value.rows)
const terminalSessionId: Validator = value =>
isRecord(value)
&& hasOnlyKeys(value, ['sessionId'])
&& isTerminalSessionId(value.sessionId)
const boundsPayload: Validator = value =>
isRecord(value)
&& typeof value.x === 'number'
&& typeof value.y === 'number'
&& typeof value.width === 'number'
&& typeof value.height === 'number'
const petInteractiveRegions: Validator = value =>
Array.isArray(value)
&& value.length > 0
&& value.length <= 8
&& value.every((region) =>
isRecord(region)
&& hasOnlyKeys(region, ['x', 'y', 'width', 'height'])
&& ['x', 'y', 'width', 'height'].every((key) =>
typeof region[key] === 'number'
&& Number.isInteger(region[key])
&& region[key] >= (key === 'width' || key === 'height' ? 1 : 0)
&& region[key] <= 2_000))
const urlWithOptionalBounds: Validator = value =>
isRecord(value)
&& typeof value.url === 'string'
&& (value.bounds === undefined || boundsPayload(value.bounds))
const zoomPayload: Validator = value => typeof value === 'number' && Number.isFinite(value)
// The colors reach BrowserWindow.setBackgroundColor, so they are pinned to a
// literal 6-digit #RRGGBB. This is load-bearing, not tidiness: that API also
// accepts #AARRGGBB, so an 8-digit value would let a compromised renderer make
// a window translucent or fully transparent — click-through and overlay
// spoofing. Do not relax this into "any CSS color".
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/
const appliedAppearance: Validator = value =>
isRecord(value)
&& hasOnlyKeys(value, ['isDark', 'background', 'lightBackground', 'followSystem'])
&& typeof value.isDark === 'boolean'
&& typeof value.followSystem === 'boolean'
&& typeof value.background === 'string'
&& HEX_COLOR.test(value.background)
&& typeof value.lightBackground === 'string'
&& HEX_COLOR.test(value.lightBackground)
const updateCheckOptions: Validator = value => {
if (value === undefined) return true
if (!isRecord(value) || !hasOnlyKeys(value, ['proxy'])) return false
return value.proxy === undefined || (typeof value.proxy === 'string' && value.proxy.trim().length > 0)
}
export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetPetAccessToken]: noPayload,
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
[ELECTRON_IPC_CHANNELS.clipboardReadText]: noPayload,
[ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
[ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload,
[ELECTRON_IPC_CHANNELS.petsList]: noPayload,
[ELECTRON_IPC_CHANNELS.petsCreateFromImage]: petCreateFromAtlas,
[ELECTRON_IPC_CHANNELS.petsCreateFromAtlas]: petCreateFromAtlas,
[ELECTRON_IPC_CHANNELS.petsOpenFolder]: noPayload,
[ELECTRON_IPC_CHANNELS.petsShow]: noPayload,
[ELECTRON_IPC_CHANNELS.petsHide]: noPayload,
[ELECTRON_IPC_CHANNELS.petsShowContextMenu]: petContextMenu,
[ELECTRON_IPC_CHANNELS.petsDragWindow]: petWindowDrag,
[ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents]: booleanPayload,
[ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions]: petInteractiveRegions,
[ELECTRON_IPC_CHANNELS.petsFocusMainWindow]: noPayload,
[ELECTRON_IPC_CHANNELS.petsFocusSession]: sessionIdPayload,
[ELECTRON_IPC_CHANNELS.dialogOpen]: optionalRecord,
[ELECTRON_IPC_CHANNELS.dialogSave]: optionalRecord,
[ELECTRON_IPC_CHANNELS.updateCheck]: updateCheckOptions,
[ELECTRON_IPC_CHANNELS.updateDownload]: noPayload,
[ELECTRON_IPC_CHANNELS.updateInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updatePrepareInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updateCancelInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updateRelaunch]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationPermissionState]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationRequestPermission]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationSend]: optionalRecord,
[ELECTRON_IPC_CHANNELS.notificationActionAck]: optionalRecord,
[ELECTRON_IPC_CHANNELS.windowMinimize]: noPayload,
[ELECTRON_IPC_CHANNELS.windowToggleMaximize]: noPayload,
[ELECTRON_IPC_CHANNELS.windowClose]: noPayload,
[ELECTRON_IPC_CHANNELS.windowStartDragging]: noPayload,
[ELECTRON_IPC_CHANNELS.windowRequestAttention]: noPayload,
[ELECTRON_IPC_CHANNELS.windowFocus]: noPayload,
[ELECTRON_IPC_CHANNELS.windowIsMaximized]: noPayload,
[ELECTRON_IPC_CHANNELS.terminalSpawn]: terminalSpawn,
[ELECTRON_IPC_CHANNELS.terminalWrite]: terminalWrite,
[ELECTRON_IPC_CHANNELS.terminalResize]: terminalResize,
[ELECTRON_IPC_CHANNELS.terminalKill]: terminalSessionId,
[ELECTRON_IPC_CHANNELS.terminalGetBashPath]: noPayload,
[ELECTRON_IPC_CHANNELS.terminalSetBashPath]: value => value === null || stringPayload(value),
[ELECTRON_IPC_CHANNELS.previewOpen]: urlWithOptionalBounds,
[ELECTRON_IPC_CHANNELS.previewNavigate]: stringPayload,
[ELECTRON_IPC_CHANNELS.previewSetBounds]: boundsPayload,
[ELECTRON_IPC_CHANNELS.previewSetVisible]: booleanPayload,
[ELECTRON_IPC_CHANNELS.previewSetZoom]: zoomPayload,
[ELECTRON_IPC_CHANNELS.previewClose]: noPayload,
[ELECTRON_IPC_CHANNELS.previewMessage]: () => true,
[ELECTRON_IPC_CHANNELS.appModeGet]: noPayload,
[ELECTRON_IPC_CHANNELS.appModeSet]: optionalRecord,
[ELECTRON_IPC_CHANNELS.appModePrepareRestart]: noPayload,
[ELECTRON_IPC_CHANNELS.appModeRestart]: noPayload,
[ELECTRON_IPC_CHANNELS.adaptersRestartSidecar]: noPayload,
[ELECTRON_IPC_CHANNELS.zoomSet]: zoomPayload,
[ELECTRON_IPC_CHANNELS.appearanceSetApplied]: appliedAppearance,
} satisfies Record<ElectronIpcChannel, Validator>
const allowedChannels = new Set<ElectronIpcChannel>(
Object.values(ELECTRON_IPC_CHANNELS),
)
const petWindowChannels = new Set<ElectronIpcChannel>([
ELECTRON_IPC_CHANNELS.runtimeGetServerUrl,
ELECTRON_IPC_CHANNELS.runtimeGetPetAccessToken,
ELECTRON_IPC_CHANNELS.petsList,
ELECTRON_IPC_CHANNELS.petsHide,
ELECTRON_IPC_CHANNELS.petsShowContextMenu,
ELECTRON_IPC_CHANNELS.petsDragWindow,
ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents,
ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions,
ELECTRON_IPC_CHANNELS.petsFocusMainWindow,
ELECTRON_IPC_CHANNELS.petsFocusSession,
])
export function isElectronIpcChannel(channel: string): channel is ElectronIpcChannel {
return allowedChannels.has(channel as ElectronIpcChannel)
}
export function validateElectronIpcPayload(channel: ElectronIpcChannel, payload: unknown): boolean {
return ELECTRON_IPC_VALIDATORS[channel](payload)
}
export function isElectronIpcChannelAllowedForPetWindow(channel: ElectronIpcChannel): boolean {
return petWindowChannels.has(channel)
}