Files
deepseek-harness/packages/client/shortcuts/src/configuration.ts
T
Xinyu Yang 91423ea1b4 revert: restore configurable Web and desktop shortcuts
Restore the feature removed by #4886 on current master. Preserve the current settings entry point, moving-menu behavior, and desktop update UI.
2026-09-22 21:02:32 +08:00

245 lines
13 KiB
TypeScript

/** Validated preference documents and deterministic conflict resolution, without browser dependencies. */
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { bindingKey, isWebBindingAllowed, normalizeBinding } from './binding.ts'
import type { NormalizedBinding, ShortcutBinding, ShortcutCommandId, ShortcutModifier, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from './binding.ts'
/** Overrides are platform-local; absent commands inherit defaults and null explicitly unbinds. */
export interface ShortcutDocument {
readonly schemaVersion: 1 | 2
readonly profiles: Readonly<Partial<Record<ShortcutProfile, Readonly<Record<string, ShortcutBinding | null>>>>>
}
/** Serializable command definitions accepted from the trusted product frame. */
export interface ShortcutDefinition {
readonly id: ShortcutCommandId
readonly defaults: Readonly<Partial<Record<ShortcutProfile, ShortcutBinding>>>
/** Fixed actions reserve one or more combinations instead of exposing an editable default. */
readonly fixed?: readonly [ShortcutBinding, ...ShortcutBinding[]]
}
/** Validation failures select localized copy in the UI. */
export type BindingIssue = 'reserved' | 'unsupported-browser' | 'modifier-required' | 'unsupported-key'
/** A conflicting row keeps its requested binding visible but cannot execute it. */
export interface EffectiveShortcut {
readonly id: ShortcutCommandId
readonly binding: NormalizedBinding | null
readonly modified: boolean
readonly conflicts: readonly ShortcutCommandId[]
readonly issue: BindingIssue | null
}
/** One revision-checked preference edit, limited to the current runtime and platform. */
export type ShortcutEdit = { type: 'set'; id: ShortcutCommandId; binding: ShortcutBinding | null }
| { type: 'reset'; id: ShortcutCommandId } | { type: 'reset-all' }
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
const commandPattern = /^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/u
/**
* Validate JSON binding fields before normalization; unknown fields are rejected to prevent lossy rewrites.
* @param value - file or IPC input.
* @returns a binding with a supported physical code, or null for explicit removal.
*/
export function parseBinding(value: unknown): ShortcutBinding | null {
if (value === null) return null
if (!record(value) || Object.keys(value).some(key => key !== 'code' && key !== 'secondCode' && key !== 'modifiers')
|| typeof value.code !== 'string' || !Array.isArray(value.modifiers)
|| (Object.hasOwn(value, 'secondCode') && typeof value.secondCode !== 'string')
|| !value.modifiers.every((modifier: unknown): modifier is ShortcutModifier =>
typeof modifier === 'string' && ['primary', 'control', 'alt', 'shift', 'meta'].includes(modifier))) {
throw new Error('Invalid shortcut binding')
}
const binding: ShortcutBinding = {
code: value.code, modifiers: value.modifiers,
...(typeof value.secondCode === 'string' ? { secondCode: value.secondCode } : {}),
}
normalizeBinding(binding, 'windows')
return binding
}
/**
* Decode the complete document while preserving dormant command overrides.
* @param raw - stored JSON, or null for a missing document.
* @returns the accepted document or a classified read failure.
*/
export function parseShortcutDocument(raw: string | null): ShortcutDocument | 'invalid' | 'future' {
if (raw === null) return { schemaVersion: 1, profiles: {} }
try {
const value: unknown = JSON.parse(raw)
if (!record(value)) return 'invalid'
if (typeof value.schemaVersion === 'number' && value.schemaVersion > 2) return 'future'
if ((value.schemaVersion !== 1 && value.schemaVersion !== 2) || !record(value.profiles)
|| Object.keys(value).some(key => key !== 'schemaVersion' && key !== 'profiles')) return 'invalid'
const profiles: Record<string, Record<string, ShortcutBinding | null>> = {}
for (const [profile, overrides] of Object.entries(value.profiles)) {
if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile) || !record(overrides)) return 'invalid'
const bindings: Record<string, ShortcutBinding | null> = {}
for (const [id, binding] of Object.entries(overrides)) {
if (!commandPattern.test(id)) return 'invalid'
const parsed = parseBinding(binding)
if (value.schemaVersion === 1 && parsed?.secondCode !== undefined) return 'invalid'
bindings[id] = parsed
}
profiles[profile] = bindings
}
return { schemaVersion: value.schemaVersion, profiles }
} catch (_error) {
// Invalid JSON and invalid binding fields both preserve the original document.
return 'invalid'
}
}
/**
* Check system, editor, and browser reservations using expanded physical modifiers.
* @param binding - normalized candidate.
* @param runtime - receiving application shell.
* @param platform - receiving device.
* @returns the rejection reason, or null when this combination is allowed.
*/
export function bindingIssue(binding: NormalizedBinding, runtime: ShortcutRuntime, platform: ShortcutPlatform): BindingIssue | null {
const { code, modifiers } = binding
if (runtime === 'desktop' && (platform === 'windows' || platform === 'macos')) return null
if (binding.secondCode !== undefined) return 'unsupported-key'
if (modifiers.length === 0 || modifiers.every(modifier => modifier === 'shift')) return 'modifier-required'
if ((platform === 'windows' || platform === 'macos') && modifiers.length >= 3) return null
if (runtime === 'web' && (platform === 'windows' || platform === 'macos') && isWebBindingAllowed(binding, platform)) return null
const primary = modifiers.includes(platform === 'macos' ? 'meta' : 'control')
if (['Escape', 'Tab', 'Space', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(code)
|| (code === 'Enter' && !modifiers.includes('alt'))
|| (primary && ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'KeyH'].includes(code))
|| (primary && code === 'KeyA' && !modifiers.includes('shift'))
|| (platform !== 'macos' && (modifiers.includes('meta') || (modifiers.includes('alt') && ['F4', 'F2'].includes(code))))
|| (platform === 'macos' && modifiers.includes('control') && modifiers.includes('meta'))
|| (platform === 'macos' && modifiers.includes('alt') && !primary)) return 'reserved'
if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) return 'unsupported-browser'
return null
}
/**
* Select the command owner's explicit default for one device profile.
* @param definition - command identity and per-profile defaults.
* @param runtime - receiving shell.
* @param platform - receiving device.
* @returns the declared physical binding, or undefined for an unbound action.
*/
export function resolveShortcutDefault(definition: ShortcutDefinition, runtime: ShortcutRuntime,
platform: ShortcutPlatform): ShortcutBinding | undefined {
return definition.defaults[`${runtime}:${platform}`]
}
/**
* Resolve overrides and conflicts independently of registration order. Explicit overrides displace defaults.
* @param definitions - active commands.
* @param document - accepted preferences.
* @param runtime - receiving shell.
* @param platform - receiving device.
* @returns every active command, including unavailable conflicting bindings.
*/
export function effectiveShortcuts(definitions: readonly ShortcutDefinition[], document: ShortcutDocument,
runtime: ShortcutRuntime, platform: ShortcutPlatform): readonly EffectiveShortcut[] {
const overrides = document.profiles[`${runtime}:${platform}`] ?? {}
const fixed = definitions.flatMap(row => row.fixed?.map(binding => ({ id: row.id, binding: normalizeBinding(binding, platform) })) ?? [])
const rows = definitions.filter(row => row.fixed === undefined).map(({ id, defaults }) => {
const modified = Object.hasOwn(overrides, id)
const candidate = modified ? overrides[id] : resolveShortcutDefault({ id, defaults }, runtime, platform)
const binding = candidate == null ? null : normalizeBinding(candidate, platform)
return { id, binding, modified, issue: binding === null ? null : bindingIssue(binding, runtime, platform) }
})
return rows.map((row) => {
const binding = row.binding
return { ...row, conflicts: binding === null ? [] : [...new Set([...rows.filter(other => other.id !== row.id
&& other.binding !== null && other.issue === null && overlappingBindings(other.binding, binding)
&& (!row.modified || other.modified)).map(other => other.id),
...fixed.filter(other => overlappingBindings(other.binding, binding)).map(other => other.id)])] }
})
}
/**
* Detect identical combinations or a single key contained in a two-key chord.
* @param left - normalized candidate.
* @param right - normalized occupied binding.
* @returns whether both bindings require the same modifiers and overlap.
*/
export function overlappingBindings(left: NormalizedBinding, right: NormalizedBinding): boolean {
if (left.modifiers.join('+') !== right.modifiers.join('+')) return false
if (left.secondCode !== undefined && right.secondCode !== undefined) return bindingKey(left) === bindingKey(right)
return [left.code, left.secondCode].some(code => code !== undefined && (code === right.code || code === right.secondCode))
}
/**
* Apply an edit without modifying other profiles or dormant overrides.
* @param document - accepted document.
* @param edit - validated operation.
* @param runtime - current shell.
* @param platform - current device.
* @returns the candidate document, pending conflict checks and durable storage.
*/
export function editShortcutDocument(document: ShortcutDocument, edit: ShortcutEdit,
runtime: ShortcutRuntime, platform: ShortcutPlatform): ShortcutDocument {
const schemaVersion = runtime === 'desktop' && (platform === 'macos' || platform === 'windows') ? 2 : document.schemaVersion
const profile = `${runtime}:${platform}` as const
let overrides = { ...document.profiles[profile] }
switch (edit.type) {
case 'set': overrides[edit.id] = edit.binding; break
case 'reset': {
const { [edit.id]: _removed, ...remaining } = overrides
overrides = remaining
break
}
case 'reset-all': overrides = {}; break
/* v8 ignore next -- parseShortcutEdit validates this closed union before persistence. */
default: return assertNever(edit, 'shortcut edit')
}
return { schemaVersion, profiles: { ...document.profiles, [profile]: overrides } }
}
/**
* Validate a preference edit at the Desktop IPC boundary.
* @param value - untrusted renderer request.
* @returns the constrained operation; malformed requests throw.
*/
export function parseShortcutEdit(value: unknown): ShortcutEdit {
if (!record(value)) throw new Error('Invalid shortcut edit')
if (value.type === 'reset-all' && Object.keys(value).length === 1) return { type: value.type }
if (typeof value.id !== 'string' || !commandPattern.test(value.id)) throw new Error('Invalid shortcut command')
if (value.type === 'reset' && Object.keys(value).length === 2) return { type: 'reset', id: value.id as ShortcutCommandId }
if (value.type === 'set' && Object.keys(value).length === 3) return { type: 'set', id: value.id as ShortcutCommandId, binding: parseBinding(value.binding) }
throw new Error('Invalid shortcut edit')
}
/**
* Validate the trusted product's serializable command catalog at IPC ingress.
* @param value - renderer-supplied active command definitions.
* @returns validated definitions; duplicate IDs, overlapping defaults, and unsupported combinations throw.
*/
export function parseShortcutDefinitions(value: unknown): readonly ShortcutDefinition[] {
if (!Array.isArray(value)) throw new Error('Invalid shortcut catalog')
const ids = new Set<string>()
for (const entry of value) {
if (!record(entry) || typeof entry.id !== 'string' || !commandPattern.test(entry.id) || ids.has(entry.id)
|| !record(entry.defaults) || Object.keys(entry).some(key => key !== 'id' && key !== 'defaults' && key !== 'fixed')) throw new Error('Invalid shortcut definition')
ids.add(entry.id)
if (Object.hasOwn(entry, 'fixed')) {
if (!Array.isArray(entry.fixed) || entry.fixed.length === 0 || Object.keys(entry.defaults).length > 0) throw new Error('Invalid fixed shortcut definition')
for (const binding of entry.fixed) if (parseBinding(binding) === null) throw new Error('Invalid fixed shortcut binding')
}
for (const [profile, candidate] of Object.entries(entry.defaults)) {
if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile)) throw new Error('Invalid shortcut profile')
const binding = parseBinding(candidate)
if (binding === null) throw new Error('Invalid shortcut default')
}
}
const definitions = value as ShortcutDefinition[]
for (const runtime of ['desktop', 'web'] as const) {
for (const platform of ['macos', 'windows', 'linux'] as const) {
const bindings: NormalizedBinding[] = []
for (const entry of definitions) {
const binding = resolveShortcutDefault(entry, runtime, platform)
if (binding === undefined) continue
const normalized = normalizeBinding(binding, platform)
if (bindings.some(other => overlappingBindings(other, normalized))
|| bindingIssue(normalized, runtime, platform) !== null) throw new Error('Conflicting or reserved shortcut default')
bindings.push(normalized)
}
}
}
return definitions
}