feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown
Lock screen (main-window overlay, no second window): - scrypt password verifier in <userData>/lock.json (per-write salt, timingSafeEqual); salt/hash/password never leave the main process - lock now / idle auto-lock / lock at startup, growing failure cooldown, lock flags persisted so a quit-and-relaunch cannot bypass the lock - locked shell and body portals go inert while sessions keep running; menu accelerators (reload, DevTools, zoom) are swallowed while locked - settings gains a Lock tab; all copy in zh-CN/zh-TW/en/ja Security and stability: - packaged builds ignore ELECTRON_RENDERER_URL / OT_UPDATE_URL (devEnv) - renderer preload runs with sandbox: true - unreadable known_hosts store fails closed instead of being overwritten - connect-time secrets gated by the bookmark's auth method (connectPromptFor) - ssh stream teardown is idempotent: PTY_EXIT broadcasts exactly once - sysinfo polling is refcounted for split panes (forceStopPolling on close) - session-log index entries are path-contained; settings store writes atomically with EPERM/EBUSY retry - sync-changelog tolerates CRLF checkouts (was a silent no-op) - retry ssh2 host-key generation (flaky malformed key, ~1/500) Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e; GitHub Actions CI (typecheck + 10 offline tests + build)
This commit is contained in:
1 parent
471f8c3e73
commit
35583b2c15
47 files changed
+3058
-105
No files matched your search
+68
-2
@@ -20,9 +20,9 @@
|
||||
|
||||
import { app, shell } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync } from 'fs'
|
||||
import { appendFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { basename, dirname, join, resolve, sep } from 'path'
|
||||
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||
import { DEFAULT_SETTINGS } from '../shared/settings'
|
||||
import { loadSettings } from './settingsStore'
|
||||
@@ -236,6 +236,9 @@ export class CommandsStore {
|
||||
typeof (x as SessionLogMeta).file === 'string'
|
||||
) {
|
||||
const meta = x as SessionLogMeta
|
||||
// index.json is data, not trust: a tampered or hand-edited `file`
|
||||
// must never turn logWrite into an arbitrary-path append.
|
||||
if (!this.isLoggableFile(meta.file)) continue
|
||||
this.metasByFile.set(meta.file, meta)
|
||||
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
|
||||
}
|
||||
@@ -245,6 +248,69 @@ export class CommandsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `file` resolves to a regular file inside the logs directory.
|
||||
*
|
||||
* Applied to every entry hydrated from index.json before it can ever reach
|
||||
* logWrite. `..` segments collapse via resolve(); containment is compared
|
||||
* case-insensitively on Windows so drive-letter or name-case spelling cannot
|
||||
* sneak a path past it. Symlinks are followed (realpathSync): an entry that
|
||||
* resolves outside the logs dir is dropped, and a path that exists but is
|
||||
* not a regular file (a directory, a device) is dropped too. A path that is
|
||||
* simply not on disk yet stays eligible — appendFile creates it lazily, and
|
||||
* the directory it would land in is still resolved.
|
||||
*/
|
||||
private isLoggableFile(file: string): boolean {
|
||||
if (file.length === 0) return false
|
||||
const dirReal = this.logsDirReal()
|
||||
let target: string
|
||||
let exists = true
|
||||
try {
|
||||
target = realpathSync(file)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false
|
||||
// Not on disk yet: appendFile would create it, so the directory it would
|
||||
// land in is what decides containment. Judging the literal path alone
|
||||
// would let a symlinked subdirectory point the append outside the dir.
|
||||
target = this.pendingPathReal(file)
|
||||
exists = false
|
||||
}
|
||||
if (exists) {
|
||||
try {
|
||||
if (!statSync(target).isFile()) return false
|
||||
} catch {
|
||||
// Vanished between realpath and stat: appends would recreate it, and
|
||||
// the containment check below already passed for this path.
|
||||
}
|
||||
}
|
||||
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p)
|
||||
const dirN = norm(dirReal)
|
||||
return norm(target).startsWith(dirN + sep)
|
||||
}
|
||||
|
||||
/** Real path of the logs dir; falls back to resolve() when it does not exist yet. */
|
||||
private logsDirReal(): string {
|
||||
try {
|
||||
return realpathSync(this.logsDir)
|
||||
} catch {
|
||||
return resolve(this.logsDir)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a log file that is not on disk yet would actually be written: its
|
||||
* parent resolved through any symlinks, the leaf kept as written (it does not
|
||||
* exist, so it has nothing to resolve). Falls back to the literal resolve()
|
||||
* when the parent is missing as well — the containment check then decides.
|
||||
*/
|
||||
private pendingPathReal(file: string): string {
|
||||
try {
|
||||
return join(realpathSync(dirname(file)), basename(file))
|
||||
} catch {
|
||||
return resolve(file)
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the full log index so listSessionLogs survives restart. */
|
||||
private persistIndex(): void {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
/**
|
||||
* Dev-only environment overrides. A packaged build must ignore these variables
|
||||
* even when they are present in its environment: whoever can inject env vars
|
||||
* into a launch (a wrapper script, a shortcut, malware with user rights) could
|
||||
* otherwise point the renderer — and with it the IPC trust check — at a remote
|
||||
* origin, or redirect the update feed to a hostile server.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Vite dev server URL, or undefined when the packaged renderer file must be
|
||||
* loaded. Packaged builds never honor ELECTRON_RENDERER_URL.
|
||||
*/
|
||||
export function devRendererUrl(): string | undefined {
|
||||
if (app.isPackaged) return undefined
|
||||
return process.env['ELECTRON_RENDERER_URL'] || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom update feed URL for development, or undefined to use the production
|
||||
* Gitea feed. Packaged builds never honor OT_UPDATE_URL (OT_UPDATE_TOKEN is
|
||||
* unrelated and still read from the environment in every build).
|
||||
*/
|
||||
export function devUpdateFeedUrl(): string | undefined {
|
||||
if (app.isPackaged) return undefined
|
||||
return process.env['OT_UPDATE_URL'] || undefined
|
||||
}
|
||||
+50
-2
@@ -2,9 +2,11 @@ import { app, BrowserWindow, globalShortcut, net, nativeImage, protocol, shell }
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { devRendererUrl } from './devEnv'
|
||||
import { isTrustedRendererUrl, registerIpc } from './ipc'
|
||||
import { killAllPtys, killPtysByOwner } from './pty'
|
||||
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
||||
import { getLockController, initLockController } from './lockController'
|
||||
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
||||
import { configureAutoUpdater, registerUpdateIpc } from './updater'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
@@ -87,6 +89,11 @@ if (!gotSingleInstanceLock) {
|
||||
// OS-level effects (login item, sleep blocker) must apply even if the
|
||||
// settings dialog is never opened this run.
|
||||
applyStartupSystemSettings(loadSettings())
|
||||
// The lock state has to exist before the window loads, so a lockAtStartup
|
||||
// lock is already in place when the renderer asks for it. It also starts the
|
||||
// idle watcher, which is why it belongs after ready: powerMonitor cannot be
|
||||
// touched before that.
|
||||
initLockController()
|
||||
createWindow()
|
||||
initTray(showOrCreate)
|
||||
// Tray labels are resolved from the dictionary at build time, so the menu has
|
||||
@@ -99,6 +106,31 @@ if (!gotSingleInstanceLock) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Accelerators that must not reach the page while the lock screen is up.
|
||||
*
|
||||
* The overlay is a DOM layer inside the window, so everything the browser
|
||||
* process handles on its own passes straight through it: reloading the renderer
|
||||
* runs Workspace's beforeunload and kills every local/SSH session behind the
|
||||
* overlay, and the zoom / DevTools shortcuts would let the locked screen be
|
||||
* resized or read. These come from Electron's default application menu, whose
|
||||
* keys are matched before any renderer code runs.
|
||||
*
|
||||
* Matching is on `input.key` rather than `input.code`: the key is what the
|
||||
* layout actually produces (Ctrl+Shift+= arrives as '+', Ctrl+Shift+- as '_'),
|
||||
* while the code depends on the physical key.
|
||||
*/
|
||||
function isLockBlockedShortcut(input: Electron.Input): boolean {
|
||||
const key = input.key.toLowerCase()
|
||||
if (key === 'f5') return true
|
||||
if (!input.control) return false
|
||||
if (key === 'r') return true
|
||||
// Zoom: in, out and reset, in both their plain and Shift-shifted spellings.
|
||||
if (key === '=' || key === '+' || key === '-' || key === '_' || key === '0') return true
|
||||
// DevTools. Shift is required so that plain Ctrl+C (copy) keeps working.
|
||||
return input.shift && (key === 'i' || key === 'j' || key === 'c')
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Dev-mode window/taskbar icon; packaged builds inherit the exe icon
|
||||
// (electron-builder embeds build/icon.png), so undefined is fine there.
|
||||
@@ -128,7 +160,21 @@ function createWindow(): void {
|
||||
...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
// Keep the default renderer sandbox (preload only touches the electron
|
||||
// IPC bridge, so it does not need Node access).
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
|
||||
// Swallow the menu accelerators that would otherwise act behind the lock
|
||||
// overlay (see isLockBlockedShortcut). A throw in here would break typing
|
||||
// altogether, so the whole guard is defensive.
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
try {
|
||||
if (input.type !== 'keyDown' || !getLockController().isLocked()) return
|
||||
if (isLockBlockedShortcut(input)) event.preventDefault()
|
||||
} catch {
|
||||
/* an input guard must never take the window down with it */
|
||||
}
|
||||
})
|
||||
|
||||
@@ -174,7 +220,9 @@ function createWindow(): void {
|
||||
if (!isTrustedRendererUrl(url)) event.preventDefault()
|
||||
})
|
||||
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
// ELECTRON_RENDERER_URL is honored in dev builds only — a packaged build must
|
||||
// always load the bundled renderer file, no matter what the environment says.
|
||||
const devUrl = devRendererUrl()
|
||||
if (devUrl) {
|
||||
win.loadURL(devUrl)
|
||||
} else {
|
||||
|
||||
+11
-5
@@ -8,9 +8,11 @@ import { pathToFileURL } from 'url'
|
||||
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
||||
import { t } from '../shared/i18n'
|
||||
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
|
||||
import { devRendererUrl } from './devEnv'
|
||||
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
|
||||
import { startPolling, stopPolling } from './sysinfo'
|
||||
import { registerSettingsIpc } from './settingsStore'
|
||||
import { registerLockIpc } from './lockController'
|
||||
import { registerSessionStateIpc } from './sessionState'
|
||||
import { normalizeReportedCwd, resolveCwd } from './cwd'
|
||||
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
|
||||
@@ -41,15 +43,17 @@ const RENDERER_FILE = join(__dirname, '../renderer/index.html')
|
||||
|
||||
/**
|
||||
* True only for a document the app itself loaded: the bundled renderer file, or
|
||||
* in dev anything served by the vite dev server. Used both to refuse a
|
||||
* navigation away from the app page and to refuse IPC from a frame that is not
|
||||
* it — a window that navigated elsewhere would still hold this preload bridge,
|
||||
* which is the whole main-process API (`createPty` included).
|
||||
* in dev anything served by the vite dev server (ELECTRON_RENDERER_URL is read
|
||||
* in dev builds only — a packaged build always trusts the production file URL,
|
||||
* never a remote origin). Used both to refuse a navigation away from the app
|
||||
* page and to refuse IPC from a frame that is not it — a window that navigated
|
||||
* elsewhere would still hold this preload bridge, which is the whole
|
||||
* main-process API (`createPty` included).
|
||||
*/
|
||||
export function isTrustedRendererUrl(raw: string): boolean {
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
try {
|
||||
const target = new URL(raw)
|
||||
const devUrl = devRendererUrl()
|
||||
if (devUrl) return target.origin === new URL(devUrl).origin
|
||||
return target.protocol === 'file:' && target.pathname === pathToFileURL(RENDERER_FILE).pathname
|
||||
} catch {
|
||||
@@ -240,6 +244,8 @@ export function registerIpc(): void {
|
||||
|
||||
registerSettingsIpc()
|
||||
registerSessionStateIpc()
|
||||
// ---- lock screen (main owns the state; the renderer only draws the overlay) ----
|
||||
registerLockIpc()
|
||||
|
||||
// Resolve a cd-style argument against the current cwd (platform-aware; only
|
||||
// the main process has node's `path`).
|
||||
|
||||
+69
-18
@@ -29,6 +29,22 @@ export type HostKeyCheckResult =
|
||||
| { status: 'match'; entry: KnownHostEntry }
|
||||
| { status: 'new' }
|
||||
| { status: 'changed'; stored: KnownHostEntry }
|
||||
/**
|
||||
* The store file exists but cannot be trusted (unreadable, corrupt JSON or
|
||||
* not the expected shape). Callers must fail closed: accepting here would
|
||||
* rewrite the store from an empty table and destroy every pinned fingerprint.
|
||||
*/
|
||||
| { status: 'unreadable' }
|
||||
|
||||
/**
|
||||
* Load outcome, so "the file was never written" (TOFU from scratch) stays
|
||||
* distinguishable from "the file is there but we cannot read it" (data loss
|
||||
* in progress — never pretend the store is empty).
|
||||
*/
|
||||
type LoadResult =
|
||||
| { kind: 'ok'; shape: KnownHostsStoreShape }
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'unreadable' }
|
||||
|
||||
/** sha256 fingerprint in ssh "SHA256:..." style (no padding) */
|
||||
export function fingerprintOf(key: Buffer): string {
|
||||
@@ -38,28 +54,42 @@ export function fingerprintOf(key: Buffer): string {
|
||||
export class KnownHostsStore {
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
private load(): KnownHostsStoreShape {
|
||||
private load(): LoadResult {
|
||||
let raw: string
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
|
||||
if (raw !== null && typeof raw === 'object') {
|
||||
const shape = raw as Partial<KnownHostsStoreShape>
|
||||
raw = readFileSync(this.filePath, 'utf8')
|
||||
} catch (err) {
|
||||
// A file that was never created is the normal first-connect case; any
|
||||
// other read failure (EACCES, EISDIR, ...) means data we cannot see.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' }
|
||||
return { kind: 'unreadable' }
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
const shape = parsed as Partial<KnownHostsStoreShape>
|
||||
if (Array.isArray(shape.entries)) {
|
||||
return {
|
||||
version: 1,
|
||||
entries: shape.entries.filter(
|
||||
(e): e is KnownHostEntry =>
|
||||
e !== null &&
|
||||
typeof e === 'object' &&
|
||||
typeof (e as KnownHostEntry).host === 'string' &&
|
||||
typeof (e as KnownHostEntry).keyBase64 === 'string'
|
||||
)
|
||||
kind: 'ok',
|
||||
shape: {
|
||||
version: 1,
|
||||
entries: shape.entries.filter(
|
||||
(e): e is KnownHostEntry =>
|
||||
e !== null &&
|
||||
typeof e === 'object' &&
|
||||
typeof (e as KnownHostEntry).host === 'string' &&
|
||||
typeof (e as KnownHostEntry).keyBase64 === 'string'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// missing / corrupted file -> start fresh
|
||||
// fall through: JSON.parse failure
|
||||
}
|
||||
return { version: 1, entries: [] }
|
||||
// Parses-but-wrong-shape is treated like corrupt: a file at this path that
|
||||
// is not the store we wrote is not evidence that nothing was pinned.
|
||||
return { kind: 'unreadable' }
|
||||
}
|
||||
|
||||
private save(shape: KnownHostsStoreShape): void {
|
||||
@@ -68,9 +98,18 @@ export class KnownHostsStore {
|
||||
|
||||
/**
|
||||
* Compare the live host key against the stored entry for (host, port).
|
||||
* Returns `unreadable` when the store exists but cannot be read — never a
|
||||
* `new` verdict, which would invite overwriting the pin data on accept.
|
||||
*/
|
||||
check(host: string, port: number, key: Buffer): HostKeyCheckResult {
|
||||
const entries = this.load().entries.filter((e) => e.host === host && e.port === port)
|
||||
const loaded = this.load()
|
||||
if (loaded.kind === 'unreadable') return { status: 'unreadable' }
|
||||
// A file that was never written is the genuine TOFU case; an unreadable
|
||||
// one was already handled above and must never degrade to 'new'.
|
||||
const entries =
|
||||
loaded.kind === 'ok'
|
||||
? loaded.shape.entries.filter((e) => e.host === host && e.port === port)
|
||||
: []
|
||||
if (entries.length === 0) return { status: 'new' }
|
||||
|
||||
const fingerprint = fingerprintOf(key)
|
||||
@@ -82,9 +121,19 @@ export class KnownHostsStore {
|
||||
return { status: 'changed', stored }
|
||||
}
|
||||
|
||||
/** Record a new host key (accept of a 'new' or 'changed' prompt). */
|
||||
/**
|
||||
* Record a new host key (accept of a 'new' or 'changed' prompt).
|
||||
*
|
||||
* Throws when the store is currently unreadable: rewriting the file from a
|
||||
* table we failed to load would wipe every other pinned fingerprint.
|
||||
*/
|
||||
accept(host: string, port: number, key: Buffer, fingerprint: string): KnownHostEntry {
|
||||
const shape = this.load()
|
||||
const loaded = this.load()
|
||||
if (loaded.kind === 'unreadable') {
|
||||
throw new Error(`known hosts store unreadable, refusing to overwrite: ${this.filePath}`)
|
||||
}
|
||||
const shape: KnownHostsStoreShape =
|
||||
loaded.kind === 'ok' ? loaded.shape : { version: 1, entries: [] }
|
||||
const entry: KnownHostEntry = {
|
||||
id: randomUUID(),
|
||||
host,
|
||||
@@ -99,8 +148,10 @@ export class KnownHostsStore {
|
||||
return entry
|
||||
}
|
||||
|
||||
/** Empty when the store is unreadable: callers must not treat that as "no pins". */
|
||||
list(): KnownHostEntry[] {
|
||||
return [...this.load().entries]
|
||||
const loaded = this.load()
|
||||
return loaded.kind === 'ok' ? [...loaded.shape.entries] : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Screen-lock controller.
|
||||
*
|
||||
* The main process owns the lock: it holds the verifier (lockStore) and the one
|
||||
* piece of live state that matters — whether the screen is currently locked.
|
||||
* Nothing here creates a window; the renderer draws the overlay for whatever
|
||||
* window it is and asks these channels for the truth, so a renderer reload (or a
|
||||
* second window, later) can never disagree about being locked.
|
||||
*
|
||||
* Every state change is published on LOCK_STATE_CHANGED, so the overlay appears
|
||||
* the moment the idle watcher fires rather than when something happens to ask.
|
||||
*/
|
||||
|
||||
import { app, ipcMain, powerMonitor } from 'electron'
|
||||
import {
|
||||
Ipc,
|
||||
type LockOperationError,
|
||||
type LockOperationResult,
|
||||
type LockPasswordInput,
|
||||
type LockSettingsState
|
||||
} from '../shared/ipc'
|
||||
import type { LockSettings } from '../shared/settings'
|
||||
import { broadcast } from './broadcast'
|
||||
import {
|
||||
LockStateStore,
|
||||
LockStore,
|
||||
defaultLockPath,
|
||||
defaultLockStatePath,
|
||||
isValidPassword
|
||||
} from './lockStore'
|
||||
import { loadSettings, mutateSettings } from './settingsStore'
|
||||
|
||||
/**
|
||||
* Backoff after each failed verification: the nth failure refuses further
|
||||
* attempts for COOLDOWN_STEPS_MS[n-1], with the last step repeating. A wrong
|
||||
* password therefore costs 1s, 2s, 5s, 10s and then 30s every time.
|
||||
*/
|
||||
const COOLDOWN_STEPS_MS = [1000, 2000, 5000, 10000, 30000]
|
||||
|
||||
/** How often the idle watcher asks the OS how long the user has been away. */
|
||||
const IDLE_POLL_MS = 15_000
|
||||
|
||||
/**
|
||||
* Upper bound applied to a cooldown restored from disk. The longest backoff step
|
||||
* is 30s, so a legitimately stored deadline can never sit further out than that;
|
||||
* anything beyond it means the clock moved backwards, and trusting the file
|
||||
* verbatim would lock the user out until the old deadline came around again.
|
||||
*/
|
||||
const MAX_RESTORED_COOLDOWN_MS = 30_000
|
||||
|
||||
export interface LockControllerOptions {
|
||||
/** injected by tests; defaults to <userData>/lock.json */
|
||||
store?: LockStore
|
||||
/** injected by tests; defaults to <userData>/lock-state.json */
|
||||
stateStore?: LockStateStore
|
||||
/** where the preferences are read from — read per call, so a settings change
|
||||
* takes effect without restarting anything */
|
||||
getLockSettings?: () => LockSettings
|
||||
publish?: (state: LockSettingsState) => void
|
||||
now?: () => number
|
||||
/** system idle time in seconds; injected so tests need no powerMonitor */
|
||||
idleSeconds?: () => number
|
||||
/** turns the lock preferences off once the password is gone; the default
|
||||
* writes them through settingsStore, which broadcasts the change itself */
|
||||
clearLockPreferences?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export class LockController {
|
||||
private readonly store: LockStore
|
||||
private readonly stateStore: LockStateStore
|
||||
private readonly getLockSettings: () => LockSettings
|
||||
private readonly publish: (state: LockSettingsState) => void
|
||||
private readonly now: () => number
|
||||
private readonly idleSeconds: () => number
|
||||
private readonly clearLockPreferences: () => void | Promise<void>
|
||||
|
||||
/** true only while a verifier exists and a lock was requested */
|
||||
private locked = false
|
||||
/** consecutive failed verifications; any success clears them */
|
||||
private failures = 0
|
||||
/** epoch ms until which attempts are refused; 0 = no cooldown */
|
||||
private cooldownUntil = 0
|
||||
private idleTimer?: ReturnType<typeof setInterval>
|
||||
/** tail of the operation queue; see serialize() */
|
||||
private queue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(options: LockControllerOptions = {}) {
|
||||
this.store = options.store ?? new LockStore(defaultLockPath(app.getPath('userData')))
|
||||
this.stateStore =
|
||||
options.stateStore ?? new LockStateStore(defaultLockStatePath(app.getPath('userData')))
|
||||
this.getLockSettings = options.getLockSettings ?? ((): LockSettings => loadSettings().lock)
|
||||
this.publish =
|
||||
options.publish ?? ((state): void => broadcast(Ipc.LOCK_STATE_CHANGED, state))
|
||||
this.now = options.now ?? ((): number => Date.now())
|
||||
this.idleSeconds = options.idleSeconds ?? ((): number => powerMonitor.getSystemIdleTime())
|
||||
this.clearLockPreferences =
|
||||
options.clearLockPreferences ??
|
||||
((): Promise<void> =>
|
||||
mutateSettings((s) => ({
|
||||
...s,
|
||||
lock: { ...s.lock, enabled: false, lockAtStartup: false }
|
||||
})).then(() => undefined))
|
||||
}
|
||||
|
||||
// ---- state -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write the live flags through to disk. Called after every change rather than
|
||||
* once at quit, because the exits that matter here are the abrupt ones: a
|
||||
* tray exit, a task-manager kill or a crash must not hand back an unlocked
|
||||
* app, and the failure count has to survive with it. The state is three
|
||||
* fields, so a synchronous write per change is not worth debouncing.
|
||||
*
|
||||
* A failed write is a warning and nothing more: losing the flags costs the
|
||||
* user one restart's worth of protection, while failing the operation they
|
||||
* just asked for would be a visible bug.
|
||||
*/
|
||||
private persist(): void {
|
||||
try {
|
||||
this.stateStore.save({
|
||||
locked: this.locked,
|
||||
failures: this.failures,
|
||||
cooldownUntil: this.cooldownUntil
|
||||
})
|
||||
} catch {
|
||||
console.warn('[lock] could not persist the lock state')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the operations one at a time. The gate reads the backoff, then awaits a
|
||||
* ~100ms scrypt verification; two calls arriving together would both clear the
|
||||
* gate and only raise the cooldown once they had both failed, so firing
|
||||
* attempts in parallel would step around the backoff entirely. Serializing
|
||||
* also means only one scrypt runs at a time in the main process.
|
||||
*/
|
||||
private serialize<T>(op: () => Promise<T>): Promise<T> {
|
||||
const run = this.queue.then(op, op)
|
||||
this.queue = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
private remainingCooldown(): number {
|
||||
return Math.max(0, this.cooldownUntil - this.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* What the renderer sees: the stored preferences plus the live lock flags.
|
||||
* Never the verifier — no salt, no hash, no password.
|
||||
*/
|
||||
getState(): LockSettingsState {
|
||||
const settings = this.getLockSettings()
|
||||
const cooldownMs = this.remainingCooldown()
|
||||
return {
|
||||
configured: this.store.isConfigured(),
|
||||
enabled: settings.enabled,
|
||||
autoLockMinutes: settings.autoLockMinutes,
|
||||
lockAtStartup: settings.lockAtStartup,
|
||||
locked: this.locked,
|
||||
...(cooldownMs > 0 ? { cooldownMs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private result(error?: LockOperationError): LockOperationResult {
|
||||
const state = this.getState()
|
||||
return error === undefined ? { ok: true, state } : { ok: false, state, error }
|
||||
}
|
||||
|
||||
/** Change the lock flag and publish, so every renderer follows immediately. */
|
||||
private applyLocked(locked: boolean): void {
|
||||
if (this.locked === locked) return
|
||||
this.locked = locked
|
||||
this.persist()
|
||||
this.publish(this.getState())
|
||||
}
|
||||
|
||||
/** Record a failed verification and (re)start the backoff. */
|
||||
private noteFailure(): void {
|
||||
const step = COOLDOWN_STEPS_MS[Math.min(this.failures, COOLDOWN_STEPS_MS.length - 1)]
|
||||
this.failures += 1
|
||||
this.cooldownUntil = this.now() + step
|
||||
this.persist()
|
||||
}
|
||||
|
||||
private resetFailures(): void {
|
||||
this.failures = 0
|
||||
this.cooldownUntil = 0
|
||||
this.persist()
|
||||
}
|
||||
|
||||
/** Refuse an attempt while the backoff is running. */
|
||||
private gate(): LockOperationResult | null {
|
||||
return this.remainingCooldown() > 0 ? this.result('cooldown') : null
|
||||
}
|
||||
|
||||
/** Passwords only ever travel in; a missing one is simply a mismatch. */
|
||||
private static passwordOf(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
// ---- operations ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set or replace the password. Replacing requires the current one: without
|
||||
* that check, anyone who walked up to an unlocked machine could install their
|
||||
* own password and keep the real user out afterwards.
|
||||
*/
|
||||
async setPassword(input: LockPasswordInput): Promise<LockOperationResult> {
|
||||
return this.serialize(async (): Promise<LockOperationResult> => {
|
||||
const next = input?.newPassword
|
||||
if (!isValidPassword(next)) return this.result('invalid-password')
|
||||
if (this.store.isConfigured()) {
|
||||
const blocked = this.gate()
|
||||
if (blocked) return blocked
|
||||
if (!(await this.store.verify(LockController.passwordOf(input?.currentPassword)))) {
|
||||
this.noteFailure()
|
||||
return this.result('wrong-password')
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.store.setPassword(next)
|
||||
} catch {
|
||||
// Deliberately not logging the error: it can quote the input, and the
|
||||
// password must not reach a log file.
|
||||
console.error('[lock] could not write the lock verifier')
|
||||
return this.result('save-failed')
|
||||
}
|
||||
this.resetFailures()
|
||||
// Whoever set the password knows it, so a freshly configured lock does not
|
||||
// slam shut on them; `locked` is left exactly as it was.
|
||||
this.publish(this.getState())
|
||||
return this.result()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the password. Verifying first is the whole point: otherwise the lock
|
||||
* could be removed by anyone at the keyboard. Clearing also unlocks, because
|
||||
* an unconfigured lock has no way to ask for anything.
|
||||
*/
|
||||
async clearPassword(input: { currentPassword?: string }): Promise<LockOperationResult> {
|
||||
return this.serialize(async (): Promise<LockOperationResult> => {
|
||||
if (!this.store.isConfigured()) {
|
||||
this.applyLocked(false)
|
||||
return this.result()
|
||||
}
|
||||
const blocked = this.gate()
|
||||
if (blocked) return blocked
|
||||
if (!(await this.store.verify(LockController.passwordOf(input?.currentPassword)))) {
|
||||
this.noteFailure()
|
||||
return this.result('wrong-password')
|
||||
}
|
||||
try {
|
||||
this.store.clear()
|
||||
} catch {
|
||||
console.error('[lock] could not remove the lock verifier')
|
||||
return this.result('save-failed')
|
||||
}
|
||||
this.locked = false
|
||||
this.resetFailures()
|
||||
// The preferences go with the password: the settings UI promises that
|
||||
// clearing turns the lock off, and leaving `enabled`/`lockAtStartup` set
|
||||
// would silently re-arm the screen the moment a new password was typed.
|
||||
try {
|
||||
await this.clearLockPreferences()
|
||||
} catch {
|
||||
console.warn('[lock] could not turn the lock preferences off')
|
||||
}
|
||||
this.publish(this.getState())
|
||||
return this.result()
|
||||
})
|
||||
}
|
||||
|
||||
/** Answer the lock screen. */
|
||||
async unlock(input: { password?: string }): Promise<LockOperationResult> {
|
||||
return this.serialize(async (): Promise<LockOperationResult> => {
|
||||
if (!this.store.isConfigured()) {
|
||||
// The verifier is gone (deleted while running): nothing could ever open
|
||||
// the screen again, so it must not stay shut.
|
||||
this.applyLocked(false)
|
||||
return this.result()
|
||||
}
|
||||
if (!this.locked) return this.result()
|
||||
const blocked = this.gate()
|
||||
if (blocked) return blocked
|
||||
if (!(await this.store.verify(LockController.passwordOf(input?.password)))) {
|
||||
this.noteFailure()
|
||||
return this.result('wrong-password')
|
||||
}
|
||||
this.locked = false
|
||||
this.resetFailures()
|
||||
this.publish(this.getState())
|
||||
return this.result()
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the screen is currently shut. Read by the main-process guards that
|
||||
* have to stop input reaching a locked window (see before-input-event). */
|
||||
isLocked(): boolean {
|
||||
return this.locked
|
||||
}
|
||||
|
||||
/** Lock the screen now. Only a configured password can lock — with no
|
||||
* verifier there would be no way back in. */
|
||||
lockNow(): LockSettingsState {
|
||||
if (this.store.isConfigured()) this.applyLocked(true)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
// ---- lifecycle -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply the startup lock and start watching for idleness. Call once the app is
|
||||
* ready: powerMonitor cannot be touched before that.
|
||||
*
|
||||
* The lock flags come back from disk, so a relaunch is not a way out of a lock
|
||||
* that was already up — an idle auto-lock or an explicit lock survives the
|
||||
* quit, exactly like `lockAtStartup` does. `locked` is assigned directly here
|
||||
* (no publish): nothing is listening yet, and the renderer asks for the state
|
||||
* as soon as it loads.
|
||||
*/
|
||||
start(): void {
|
||||
const restored = this.stateStore.load()
|
||||
this.failures = restored.failures
|
||||
// Clamped: see MAX_RESTORED_COOLDOWN_MS.
|
||||
this.cooldownUntil = Math.min(restored.cooldownUntil, this.now() + MAX_RESTORED_COOLDOWN_MS)
|
||||
if (this.store.isConfigured()) {
|
||||
if (this.getLockSettings().lockAtStartup || restored.locked) this.locked = true
|
||||
} else {
|
||||
// No verifier means nothing could ever open the screen again, so the
|
||||
// stored flags must not outlive it (a later password would re-arm a lock
|
||||
// nobody asked for).
|
||||
try {
|
||||
this.stateStore.clear()
|
||||
} catch {
|
||||
console.warn('[lock] could not clear the persisted lock state')
|
||||
}
|
||||
}
|
||||
if (this.idleTimer === undefined) {
|
||||
this.idleTimer = setInterval(() => this.checkIdle(), IDLE_POLL_MS)
|
||||
// Polling for idleness must never hold the process open.
|
||||
this.idleTimer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle auto-lock. Only a configured, enabled lock with a real delay may fire,
|
||||
* and never while the screen is already locked — otherwise every poll would
|
||||
* republish the same state.
|
||||
*/
|
||||
private checkIdle(): void {
|
||||
const settings = this.getLockSettings()
|
||||
if (!settings.enabled || settings.autoLockMinutes <= 0) return
|
||||
if (this.locked || !this.store.isConfigured()) return
|
||||
let idleSeconds: number
|
||||
try {
|
||||
idleSeconds = this.idleSeconds()
|
||||
} catch {
|
||||
// powerMonitor refuses without a session (or on a locked workstation);
|
||||
// "unknown" must read as "not idle" rather than locking the app.
|
||||
return
|
||||
}
|
||||
if (idleSeconds >= settings.autoLockMinutes * 60) this.applyLocked(true)
|
||||
}
|
||||
}
|
||||
|
||||
let controller: LockController | undefined
|
||||
|
||||
export function getLockController(): LockController {
|
||||
if (!controller) controller = new LockController()
|
||||
return controller
|
||||
}
|
||||
|
||||
/** Start the idle watcher and apply the startup lock (call after app ready). */
|
||||
export function initLockController(): LockController {
|
||||
const instance = getLockController()
|
||||
instance.start()
|
||||
return instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the lock channels. Goes through `ipcMain.handle` like every other
|
||||
* channel, so the sender guard installed by registerIpc covers these too.
|
||||
*/
|
||||
export function registerLockIpc(): void {
|
||||
const lock = getLockController()
|
||||
ipcMain.handle(Ipc.LOCK_STATE_GET, () => lock.getState())
|
||||
ipcMain.handle(Ipc.LOCK_SET_PASSWORD, (_event, input: LockPasswordInput) =>
|
||||
lock.setPassword(input ?? {})
|
||||
)
|
||||
ipcMain.handle(Ipc.LOCK_CLEAR_PASSWORD, (_event, input: { currentPassword?: string }) =>
|
||||
lock.clearPassword(input ?? {})
|
||||
)
|
||||
ipcMain.handle(Ipc.LOCK_UNLOCK, (_event, input: { password?: string }) =>
|
||||
lock.unlock(input ?? {})
|
||||
)
|
||||
ipcMain.handle(Ipc.LOCK_NOW, () => lock.lockNow())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Screen-lock stores. The verifier persists to <userData>/lock.json, the live
|
||||
* lock flags (locked / failures / cooldownUntil) to <userData>/lock-state.json.
|
||||
*
|
||||
* No Electron imports here: both file locations are injected, the same way the
|
||||
* known-hosts store does it, so the module can be driven by a plain-Node test.
|
||||
*
|
||||
* What lands on disk is a scrypt verifier, never the password: a random 16-byte
|
||||
* salt plus scrypt(password, salt, 64), both base64. The comparison goes through
|
||||
* timingSafeEqual so a wrong password cannot be narrowed down by response time,
|
||||
* and the password is never logged, echoed back or put in an error message.
|
||||
*
|
||||
* A missing, truncated, wrong-shaped or wrongly-sized verifier file reads as
|
||||
* "not configured": losing the lock is a nuisance, while throwing out of a
|
||||
* startup path would make the app unstartable. The state store is just as
|
||||
* forgiving, for the same reason.
|
||||
*/
|
||||
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'crypto'
|
||||
import { unlinkSync } from 'fs'
|
||||
import { readJson, writeJson } from './store'
|
||||
|
||||
/** Shape written to disk; `version` gates any future migration. */
|
||||
export interface LockStoreShape {
|
||||
version: 1
|
||||
/** random per-install salt, base64 */
|
||||
salt: string
|
||||
/** scrypt(password, salt, KEY_LENGTH), base64 */
|
||||
hash: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const SALT_BYTES = 16
|
||||
const KEY_LENGTH = 64
|
||||
|
||||
/**
|
||||
* scrypt cost parameters, spelled out rather than left to node's defaults so the
|
||||
* verifier cannot be silently re-tuned by a runtime upgrade (an existing hash
|
||||
* has to stay checkable).
|
||||
*/
|
||||
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
|
||||
|
||||
export const MIN_PASSWORD_LENGTH = 4
|
||||
export const MAX_PASSWORD_LENGTH = 128
|
||||
|
||||
/** An empty or absurdly long password is refused rather than hashed. */
|
||||
export function isValidPassword(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length >= MIN_PASSWORD_LENGTH &&
|
||||
value.length <= MAX_PASSWORD_LENGTH
|
||||
)
|
||||
}
|
||||
|
||||
/** Async on purpose: scryptSync would block the main process (which streams PTY
|
||||
* output) for ~100ms on every attempt. */
|
||||
function derive(password: string, salt: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, KEY_LENGTH, SCRYPT_OPTIONS, (err, key) => {
|
||||
if (err) reject(err)
|
||||
else resolve(key)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export class LockStore {
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
/** The load path runs on every state query, so the unknown-version warning
|
||||
* must fire once per process rather than once per call. */
|
||||
private static warnedUnknownVersion = false
|
||||
|
||||
/** The stored verifier, or null when unconfigured or unusable. */
|
||||
private load(): LockStoreShape | null {
|
||||
const parsed = readJson<Partial<LockStoreShape>>(this.filePath)
|
||||
if (parsed === null || typeof parsed !== 'object') return null
|
||||
if (parsed.version !== 1) {
|
||||
// Fail-open is deliberate (a lock file nobody can read must not make the
|
||||
// app unstartable), but a future format must not disable the lock
|
||||
// silently: the file exists, says it holds a verifier, and is being
|
||||
// ignored. One warning per process; the message quotes nothing from it.
|
||||
if (!LockStore.warnedUnknownVersion) {
|
||||
LockStore.warnedUnknownVersion = true
|
||||
console.warn(
|
||||
'[lock] lock.json has a version this build does not know; reading it as not configured — the password must be set again'
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (typeof parsed.salt !== 'string' || typeof parsed.hash !== 'string') return null
|
||||
if (typeof parsed.createdAt !== 'number') return null
|
||||
const salt = Buffer.from(parsed.salt, 'base64')
|
||||
const hash = Buffer.from(parsed.hash, 'base64')
|
||||
// A verifier of the wrong size is a corrupt file, not a weak password: it
|
||||
// can never match, so it must not count as "a password is set".
|
||||
if (salt.length !== SALT_BYTES || hash.length !== KEY_LENGTH) return null
|
||||
return { version: 1, salt: parsed.salt, hash: parsed.hash, createdAt: parsed.createdAt }
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.load() !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fresh verifier — first set and replace alike, because the salt is
|
||||
* regenerated so the previous hash (and anyone who saw it) becomes worthless.
|
||||
* Throws on an unusable password; the caller maps that to `invalid-password`.
|
||||
*/
|
||||
async setPassword(password: string): Promise<void> {
|
||||
if (!isValidPassword(password)) {
|
||||
throw new Error(
|
||||
`lock password must be ${MIN_PASSWORD_LENGTH}..${MAX_PASSWORD_LENGTH} characters`
|
||||
)
|
||||
}
|
||||
const salt = randomBytes(SALT_BYTES)
|
||||
const hash = await derive(password, salt)
|
||||
const shape: LockStoreShape = {
|
||||
version: 1,
|
||||
salt: salt.toString('base64'),
|
||||
hash: hash.toString('base64'),
|
||||
createdAt: Date.now()
|
||||
}
|
||||
writeJson(this.filePath, shape)
|
||||
}
|
||||
|
||||
/** Constant-time check. False when unconfigured; never throws. */
|
||||
async verify(password: string): Promise<boolean> {
|
||||
const stored = this.load()
|
||||
if (stored === null || typeof password !== 'string') return false
|
||||
const expected = Buffer.from(stored.hash, 'base64')
|
||||
const actual = await derive(password, Buffer.from(stored.salt, 'base64'))
|
||||
// Lengths are equal by construction (load() enforces it); the guard keeps
|
||||
// timingSafeEqual from throwing on a file that changed underneath us.
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
/** Forget the password: the file is deleted, so "not configured" is one state
|
||||
* rather than two (an empty file vs. no file). */
|
||||
clear(): void {
|
||||
try {
|
||||
unlinkSync(this.filePath)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default location: <userData>/lock.json */
|
||||
export function defaultLockPath(userDataPath: string): string {
|
||||
return `${userDataPath}/lock.json`
|
||||
}
|
||||
|
||||
/** The live lock flags, as persisted and as held in memory by the controller. */
|
||||
export interface LockState {
|
||||
locked: boolean
|
||||
failures: number
|
||||
cooldownUntil: number
|
||||
}
|
||||
|
||||
/** Shape written to disk; `version` gates any future migration. */
|
||||
interface LockStateShape extends LockState {
|
||||
version: 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence for the lock flags themselves, so quitting and relaunching is not
|
||||
* a way out of a lock that was already up (nor a way to reset the backoff that
|
||||
* was already earned). Only flags live here — never a password, never a hash.
|
||||
*
|
||||
* Like the verifier store, this reads a missing/corrupt/wrong-shaped file as
|
||||
* "nothing was ever locked": the load happens on the startup path, where
|
||||
* throwing would leave the app without a window but still holding the
|
||||
* single-instance lock.
|
||||
*/
|
||||
export class LockStateStore {
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
/** The stored flags, or "unlocked with no failures" for anything unusable. */
|
||||
load(): LockState {
|
||||
const fallback: LockState = { locked: false, failures: 0, cooldownUntil: 0 }
|
||||
const parsed = readJson<Partial<LockStateShape>>(this.filePath)
|
||||
if (parsed === null || typeof parsed !== 'object') return fallback
|
||||
if (parsed.version !== 1) return fallback
|
||||
const { failures, cooldownUntil } = parsed
|
||||
return {
|
||||
locked: parsed.locked === true,
|
||||
failures:
|
||||
typeof failures === 'number' && Number.isInteger(failures) && failures > 0 ? failures : 0,
|
||||
cooldownUntil:
|
||||
typeof cooldownUntil === 'number' && Number.isFinite(cooldownUntil) && cooldownUntil > 0
|
||||
? cooldownUntil
|
||||
: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic write (temp file + rename), so a crash mid-write cannot leave a
|
||||
* half-written file that would read back as "never locked". */
|
||||
save(state: LockState): void {
|
||||
const shape: LockStateShape = { version: 1, ...state }
|
||||
writeJson(this.filePath, shape)
|
||||
}
|
||||
|
||||
/** Forget the flags: the file is deleted, so "not locked" is one state rather
|
||||
* than two (an empty file vs. no file). */
|
||||
clear(): void {
|
||||
try {
|
||||
unlinkSync(this.filePath)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default location: <userData>/lock-state.json */
|
||||
export function defaultLockStatePath(userDataPath: string): string {
|
||||
return `${userDataPath}/lock-state.json`
|
||||
}
|
||||
+33
-7
@@ -8,7 +8,7 @@ import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../s
|
||||
import { broadcast } from './broadcast'
|
||||
import { connectSsh, type SshSessionHandle } from './ssh'
|
||||
import type { HostKeyCheckResult } from './knownHosts'
|
||||
import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo'
|
||||
import { configureSysinfo, registerSysinfoClient, forceStopPolling } from './sysinfo'
|
||||
import { registerSftpClientProvider, closeSftp } from './sftp'
|
||||
import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem'
|
||||
import { pickAdapter } from './shellIntegration'
|
||||
@@ -17,7 +17,7 @@ import { statSync } from 'fs'
|
||||
|
||||
// Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the
|
||||
// M3 polling engine without importing src/main/sysinfo.ts separately.
|
||||
export { startPolling, stopPolling } from './sysinfo'
|
||||
export { startPolling, stopPolling, forceStopPolling } from './sysinfo'
|
||||
|
||||
/**
|
||||
* M5 command-store / log-service hooks (injected once by ipc.ts). Mirrors the
|
||||
@@ -308,16 +308,40 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
|
||||
if (typeof code === 'number') handle.exitCode = code
|
||||
})
|
||||
|
||||
handle.stream.on('close', () => {
|
||||
// One teardown for both terminal events. ssh2 emits 'close' after an 'error'
|
||||
// (and killSession closes the stream directly), so the path must be
|
||||
// idempotent: the flag guarantees PTY_EXIT is broadcast exactly once and the
|
||||
// polling / SFTP / ZMODEM / log cleanups run at most once per session.
|
||||
let sshClosed = false
|
||||
const teardownSshStream = (exitCode: number): void => {
|
||||
if (sshClosed) return
|
||||
sshClosed = true
|
||||
try {
|
||||
stopPolling(handle.id)
|
||||
// Session is gone: no shared poll may survive any remaining panel refs.
|
||||
forceStopPolling(handle.id)
|
||||
closeSftp(handle.id)
|
||||
detachZmodem(handle.id)
|
||||
safeStopLog(handle.id)
|
||||
sessions.delete(handle.id)
|
||||
replayBuffers.delete(handle.id)
|
||||
sshDecoders.delete(handle.id)
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: handle.exitCode })
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
}
|
||||
|
||||
handle.stream.on('close', () => {
|
||||
teardownSshStream(handle.exitCode)
|
||||
})
|
||||
|
||||
handle.stream.on('error', (err: Error) => {
|
||||
// 'close' follows an 'error', but rely on the idempotent teardown instead
|
||||
// of waiting for it: a dead stream must release its session slot at once.
|
||||
try {
|
||||
console.warn(`[pty] ssh stream error (${handle.id}): ${err.message}`)
|
||||
if (handle.exitCode === 0) handle.exitCode = 1
|
||||
teardownSshStream(handle.exitCode)
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
@@ -353,7 +377,9 @@ export function resizePty(id: string, cols: number, rows: number): void {
|
||||
}
|
||||
|
||||
function killSession(id: string): void {
|
||||
stopPolling(id)
|
||||
// Forced: the session is being destroyed, so the shared poll must stop even
|
||||
// if split panels still hold references.
|
||||
forceStopPolling(id)
|
||||
closeSftp(id)
|
||||
detachZmodem(id)
|
||||
safeStopLog(id)
|
||||
@@ -398,7 +424,7 @@ export function killPtysByOwner(owner: number): void {
|
||||
|
||||
export function killAllPtys(): void {
|
||||
for (const id of sessions.keys()) {
|
||||
stopPolling(id)
|
||||
forceStopPolling(id)
|
||||
closeSftp(id)
|
||||
detachZmodem(id)
|
||||
safeStopLog(id)
|
||||
|
||||
+38
-14
@@ -1,13 +1,16 @@
|
||||
import { app, ipcMain, powerSaveBlocker } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import {
|
||||
DEFAULT_HIGHLIGHT_RULES,
|
||||
DEFAULT_SETTINGS,
|
||||
isHighlightCategory,
|
||||
isLockAutoDelay,
|
||||
lockAutoDelayOf,
|
||||
type AppSettings,
|
||||
type HighlightRule,
|
||||
type LockSettings,
|
||||
type SystemSettings,
|
||||
type TerminalSettings
|
||||
} from '../shared/settings'
|
||||
@@ -15,6 +18,7 @@ import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/th
|
||||
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
|
||||
import { sanitizeProfiles } from '../shared/highlightProfiles'
|
||||
import { broadcast } from './broadcast'
|
||||
import { writeJson } from './store'
|
||||
import { applyGlobalShortcut } from './globalShortcuts'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
|
||||
@@ -267,8 +271,31 @@ function sanitizeThemes(value: unknown, warnings: Warnings): TerminalTheme[] {
|
||||
return themes
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the lock block. Every field is forced to its type, so a partial
|
||||
* patch, a hand-edited file or a config written before the block existed all
|
||||
* land on the defaults; the delay must be one of the offered steps, because an
|
||||
* arbitrary number here would silently change the idle-lock schedule.
|
||||
*/
|
||||
function sanitizeLock(value: unknown, errors: string[]): LockSettings {
|
||||
const candidate =
|
||||
value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
||||
if (candidate.autoLockMinutes !== undefined && !isLockAutoDelay(candidate.autoLockMinutes)) {
|
||||
errors.push('lock.autoLockMinutes')
|
||||
}
|
||||
return {
|
||||
enabled: candidate.enabled === true,
|
||||
autoLockMinutes: lockAutoDelayOf(candidate.autoLockMinutes),
|
||||
lockAtStartup: candidate.lockAtStartup === true
|
||||
}
|
||||
}
|
||||
|
||||
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
const lock = sanitizeLock(
|
||||
raw !== null && typeof raw === 'object' ? (raw as { lock?: unknown }).lock : undefined,
|
||||
errors
|
||||
)
|
||||
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
|
||||
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
|
||||
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
|
||||
@@ -345,7 +372,8 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
new Set(rules.map((rule) => rule.id)),
|
||||
(message) => errors.push(message)
|
||||
),
|
||||
system: system as SystemSettings
|
||||
system: system as SystemSettings,
|
||||
lock
|
||||
},
|
||||
errors
|
||||
}
|
||||
@@ -419,7 +447,8 @@ export function loadSettings(): AppSettings {
|
||||
customThemes: [...DEFAULT_SETTINGS.customThemes],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
|
||||
highlightProfiles: [],
|
||||
system: { ...DEFAULT_SYSTEM }
|
||||
system: { ...DEFAULT_SYSTEM },
|
||||
lock: { ...DEFAULT_SETTINGS.lock }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,11 +469,7 @@ function migrateDefaultsOnce(): void {
|
||||
if (current.terminal.suggestEnabled || current.terminal.historyEnabled) {
|
||||
current.terminal.suggestEnabled = false
|
||||
current.terminal.historyEnabled = false
|
||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||
const path = settingsPath()
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf8')
|
||||
renameSync(tmp, path)
|
||||
writeJson(settingsPath(), current)
|
||||
}
|
||||
writeFileSync(flag, '', 'utf8')
|
||||
} catch {
|
||||
@@ -464,11 +489,9 @@ export function mutateSettings(mutate: (settings: AppSettings) => AppSettings):
|
||||
applySystemSettings(merged.system)
|
||||
applyWindowChrome(merged)
|
||||
|
||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||
const path = settingsPath()
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf8')
|
||||
renameSync(tmp, path)
|
||||
// writeJson gives the same atomic write as every other store, including
|
||||
// short EPERM/EBUSY retries when Windows holds the destination open.
|
||||
writeJson(settingsPath(), merged)
|
||||
|
||||
broadcast(Ipc.SETTINGS_CHANGED, merged)
|
||||
return merged
|
||||
@@ -492,7 +515,8 @@ export function saveSettings(next: Partial<AppSettings>): Promise<AppSettings> {
|
||||
...current,
|
||||
...next,
|
||||
terminal: { ...current.terminal, ...next.terminal },
|
||||
system: { ...current.system, ...next.system }
|
||||
system: { ...current.system, ...next.system },
|
||||
lock: { ...current.lock, ...next.lock }
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+30
-13
@@ -15,7 +15,6 @@
|
||||
|
||||
import type { SshConnection, SshSecretOverride } from '../shared/connections'
|
||||
import { t } from '../shared/i18n'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { readFileSync } from 'fs'
|
||||
import { fingerprintOf, type HostKeyCheckResult } from './knownHosts'
|
||||
@@ -112,14 +111,16 @@ export async function connectSsh(
|
||||
// Called by ssh2 during kex; return undefined => async verdict via verify().
|
||||
const hostVerifier = (hostKey: Buffer, verify: (permitted: boolean) => void): void => {
|
||||
let fingerprint: string
|
||||
let status: 'new' | 'changed' | 'match'
|
||||
let status: HostKeyCheckResult['status']
|
||||
try {
|
||||
fingerprint = fingerprintOf(hostKey)
|
||||
status = deps.knownHosts.check(conn.host, conn.port, hostKey).status
|
||||
} catch (err) {
|
||||
console.error(`[ssh] knownHosts.check threw: ${(err as Error).message}`)
|
||||
fingerprint = fingerprintOf(hostKey)
|
||||
status = 'new'
|
||||
// A throwing store is as untrustworthy as an unreadable one: falling back
|
||||
// to 'new' would let the subsequent accept() rewrite the whole store.
|
||||
status = 'unreadable'
|
||||
}
|
||||
|
||||
if (status === 'match') {
|
||||
@@ -127,6 +128,17 @@ export async function connectSsh(
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'unreadable') {
|
||||
// known_hosts exists but cannot be read (corrupt JSON, access error...).
|
||||
// Accepting would persist the new key into a table we failed to load and
|
||||
// destroy every pinned fingerprint, so fail closed instead of prompting:
|
||||
// no accept offer, the user repairs or deletes the file and retries.
|
||||
verifierErr = t('main.ssh.knownHostsUnreadable')
|
||||
console.error(`[ssh] ${verifierErr}`)
|
||||
verify(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Pause the connect timeout; the user's decision owns this wait.
|
||||
if (connectTimer) clearTimeout(connectTimer)
|
||||
|
||||
@@ -168,13 +180,11 @@ export async function connectSsh(
|
||||
}
|
||||
// Session already established: surface as a session exit and clean up.
|
||||
// Record the failure code on the handle so the stream's later 'close'
|
||||
// (pty.ts) reports the same exit code instead of a bogus 0.
|
||||
// (pty.ts teardown) reports the same exit code instead of a bogus 0 —
|
||||
// the broadcast itself must come only from pty.ts's idempotent teardown;
|
||||
// emitting it here as well would fire PTY_EXIT twice (destroy() closes
|
||||
// the stream, and the stream 'close' handler broadcasts on its own).
|
||||
if (sessionHandle) sessionHandle.exitCode = 1
|
||||
try {
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
try {
|
||||
handshake.destroy()
|
||||
} catch {
|
||||
@@ -228,11 +238,14 @@ export async function connectSsh(
|
||||
// Password auth. A stored password is offered only when the bookmark is
|
||||
// configured for password auth: connectionsStore keeps password_enc when a
|
||||
// bookmark is switched to key/agent auth, and silently falling back to it
|
||||
// would authenticate a weaker method than the user chose. An explicitly
|
||||
// typed connect-time password (secretOverride) is always honoured.
|
||||
// would authenticate a weaker method than the user chose. The same gate
|
||||
// applies to a typed connect-time password (secretOverride): it belongs to
|
||||
// the password flow and must never upgrade a key/agent bookmark into
|
||||
// password auth (a stale ask flag used to route one here via ConnectFlow).
|
||||
const password =
|
||||
secretOverride?.password ??
|
||||
(conn.auth === 'password' ? deps.connections.getSecret(conn, 'password') : undefined)
|
||||
conn.auth === 'password'
|
||||
? (secretOverride?.password ?? deps.connections.getSecret(conn, 'password'))
|
||||
: undefined
|
||||
if (password !== undefined) cfg.password = password
|
||||
|
||||
// Private key auth (keyPath takes precedence over stored keyContent)
|
||||
@@ -246,6 +259,10 @@ export async function connectSsh(
|
||||
: undefined
|
||||
if (privateKey !== undefined) {
|
||||
cfg.privateKey = privateKey
|
||||
// A typed connect-time passphrase (secretOverride) is honoured only
|
||||
// inside this private-key branch — the gate above keeps the password
|
||||
// override out of key auth and this branch keeps the passphrase out of
|
||||
// password auth.
|
||||
const passphrase = secretOverride?.passphrase ?? deps.connections.getSecret(conn, 'passphrase')
|
||||
if (passphrase !== undefined) cfg.passphrase = passphrase
|
||||
}
|
||||
|
||||
+42
-5
@@ -75,16 +75,29 @@ interface PollState {
|
||||
prevAt: number
|
||||
metaSent: boolean
|
||||
timer: NodeJS.Timeout
|
||||
/**
|
||||
* Live renderer subscriptions on this poll. Two MonitorPanels can share one
|
||||
* sessionId (split view); each start/stop pair adjusts the count and only a
|
||||
* count of zero (or a forced stop) tears the poll down.
|
||||
*/
|
||||
refs: number
|
||||
}
|
||||
|
||||
const polls = new Map<string, PollState>()
|
||||
|
||||
/**
|
||||
* Start polling a session. Calling again for the same id stops the previous
|
||||
* poll first (re-entrancy safe).
|
||||
* Start polling a session on behalf of one renderer subscriber.
|
||||
*
|
||||
* The first call creates the poll; further calls for an already-polling id
|
||||
* only bump the reference count (the existing interval keeps running) so a
|
||||
* second panel joining a split view cannot restart or reset the shared poll.
|
||||
*/
|
||||
export function startPolling(id: string, intervalMs = 3000): void {
|
||||
stopPolling(id)
|
||||
const existing = polls.get(id)
|
||||
if (existing) {
|
||||
existing.refs += 1
|
||||
return
|
||||
}
|
||||
const state: PollState = {
|
||||
id,
|
||||
intervalMs,
|
||||
@@ -94,13 +107,34 @@ export function startPolling(id: string, intervalMs = 3000): void {
|
||||
lastSample: undefined,
|
||||
prevAt: 0,
|
||||
metaSent: false,
|
||||
refs: 1,
|
||||
timer: setTimeout(() => pollOnce(id, state), 0)
|
||||
}
|
||||
polls.set(id, state)
|
||||
}
|
||||
|
||||
/** Stop polling a session (no-op when not polling). Also run on session close. */
|
||||
/**
|
||||
* Drop one renderer subscription. The poll itself stops only when the last
|
||||
* reference is gone — any other panel sharing the sessionId keeps it alive.
|
||||
* No-op when nothing is polling (e.g. after a forced stop).
|
||||
*/
|
||||
export function stopPolling(id: string): void {
|
||||
const state = polls.get(id)
|
||||
if (!state) return
|
||||
state.refs -= 1
|
||||
if (state.refs <= 0) haltPolling(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop unconditionally, discarding the reference count. For session
|
||||
* close/kill: after the underlying ssh client is gone nothing must keep
|
||||
* polling, regardless of how many panels still hold references.
|
||||
*/
|
||||
export function forceStopPolling(id: string): void {
|
||||
haltPolling(id)
|
||||
}
|
||||
|
||||
function haltPolling(id: string): void {
|
||||
const state = polls.get(id)
|
||||
if (!state) return
|
||||
state.stopped = true
|
||||
@@ -199,7 +233,10 @@ function handleError(id: string, state: PollState, message: string): void {
|
||||
|
||||
if (state.consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
|
||||
console.warn(`[sysinfo] session ${id} failed ${state.consecutiveFails} polls, stopping`)
|
||||
stopPolling(id)
|
||||
// Engine-side decision: halt the poll outright (not a refcount decrement —
|
||||
// that would leave sibling panels pointing at a dead loop with no timer).
|
||||
// A later renderer stop is then a no-op and a fresh start can re-create it.
|
||||
forceStopPolling(id)
|
||||
return
|
||||
}
|
||||
armNext(id, state)
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ import { autoUpdater } from 'electron-updater'
|
||||
import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc'
|
||||
import { t } from '../shared/i18n'
|
||||
import { broadcast } from './broadcast'
|
||||
import { devUpdateFeedUrl } from './devEnv'
|
||||
import { loadSettings } from './settingsStore'
|
||||
import { markQuitting } from './tray'
|
||||
|
||||
@@ -32,11 +33,15 @@ function useFeed(feed: 'gitea' | 'github'): void {
|
||||
activeFeed = feed
|
||||
if (feed === 'gitea') {
|
||||
// Domestic feed: always direct — a system proxy only breaks it.
|
||||
// OT_UPDATE_URL overrides the feed in dev builds only; OT_UPDATE_TOKEN is
|
||||
// read from the environment in every build, which is only safe because the
|
||||
// packaged URL is the hardcoded GITEA_FEED — making it configurable again
|
||||
// would turn the token into a credential sent to whatever host it names.
|
||||
void autoUpdater.netSession.setProxy({ mode: 'direct' })
|
||||
const token = process.env.OT_UPDATE_TOKEN
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'generic',
|
||||
url: process.env.OT_UPDATE_URL || GITEA_FEED,
|
||||
url: devUpdateFeedUrl() ?? GITEA_FEED,
|
||||
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Ipc } from '../shared/ipc'
|
||||
import type { AppSettings } from '../shared/settings'
|
||||
import type { AppApi } from '../shared/api'
|
||||
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, SessionSnapshot, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
|
||||
import type { LockOperationResult, LockPasswordInput, LockSettingsState } from '../shared/ipc'
|
||||
import type {
|
||||
HostKeyAction,
|
||||
HostKeyPromptEvent,
|
||||
@@ -113,6 +114,18 @@ const api: AppApi = {
|
||||
return () => ipcRenderer.removeListener(Ipc.SETTINGS_CHANGED, listener)
|
||||
},
|
||||
|
||||
getLockState: () => ipcRenderer.invoke(Ipc.LOCK_STATE_GET),
|
||||
setLockPassword: (input: LockPasswordInput) => ipcRenderer.invoke(Ipc.LOCK_SET_PASSWORD, input),
|
||||
clearLockPassword: (input: { currentPassword?: string }) =>
|
||||
ipcRenderer.invoke(Ipc.LOCK_CLEAR_PASSWORD, input),
|
||||
unlockLock: (input: { password?: string }) => ipcRenderer.invoke(Ipc.LOCK_UNLOCK, input),
|
||||
lockNow: () => ipcRenderer.invoke(Ipc.LOCK_NOW),
|
||||
onLockStateChanged: (cb: (state: LockSettingsState) => void) => {
|
||||
const listener = (_: unknown, state: LockSettingsState): void => cb(state)
|
||||
ipcRenderer.on(Ipc.LOCK_STATE_CHANGED, listener)
|
||||
return () => ipcRenderer.removeListener(Ipc.LOCK_STATE_CHANGED, listener)
|
||||
},
|
||||
|
||||
listFonts: () => ipcRenderer.invoke(Ipc.FONTS_LIST),
|
||||
|
||||
listLayouts: () => ipcRenderer.invoke(Ipc.LAYOUTS_LIST),
|
||||
|
||||
@@ -7,9 +7,11 @@ import jaJP from 'antd/locale/ja_JP'
|
||||
import Workspace from '@renderer/workspace/Workspace'
|
||||
import { SettingsDialog } from '@renderer/settings/SettingsDialog'
|
||||
import { TransferPanel } from '@renderer/sftp/TransferPanel'
|
||||
import { LockScreen } from '@renderer/lock/LockScreen'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import { getThemeById } from '@shared/theme'
|
||||
import { DEFAULT_LANGUAGE, syncLanguage, type Language } from '@shared/i18n'
|
||||
import type { LockSettingsState } from '@shared/ipc'
|
||||
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
|
||||
import appIconUrl from '../../../build/icon.png'
|
||||
|
||||
@@ -44,11 +46,81 @@ export default function App(): React.JSX.Element {
|
||||
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
|
||||
const storedLanguage = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
/** Secure default: main may already hold a startup lock before this IPC
|
||||
* resolves, so the shell must not become interactive while unknown. `null`
|
||||
* means "not answered yet" and is kept distinct from "locked": the overlay
|
||||
* is drawn only once main has spoken, otherwise every start would flash a
|
||||
* lock panel — even for users who never configured a password. */
|
||||
const [lockState, setLockState] = useState<LockSettingsState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void hydrate()
|
||||
}, [hydrate])
|
||||
|
||||
// Lock state: pulled once (it may already be locked at startup) and then kept
|
||||
// in sync from main, which owns the state — the renderer never decides.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
void window.api.getLockState().then(
|
||||
(state: LockSettingsState) => {
|
||||
if (alive) setLockState(state)
|
||||
},
|
||||
(err: unknown) => {
|
||||
console.error('[lock] getLockState failed', err)
|
||||
// Stay recoverable: fall back to "locked with no verifier" so the panel
|
||||
// (and its input) is reachable. Main unlocks an unconfigured app on the
|
||||
// first attempt, so the user is never stuck on the boot layer.
|
||||
if (alive) {
|
||||
setLockState({
|
||||
configured: false,
|
||||
enabled: false,
|
||||
autoLockMinutes: 0,
|
||||
lockAtStartup: false,
|
||||
locked: true
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
const off = window.api.onLockStateChanged((state: LockSettingsState) => setLockState(state))
|
||||
return () => {
|
||||
alive = false
|
||||
off()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Unknown counts as locked: main owns the state and may already be locked.
|
||||
const locked = lockState === null || lockState.locked
|
||||
|
||||
// Locking is app-wide: close antd portals that live outside `.app-root`
|
||||
// (the settings modal otherwise stays focusable behind the opaque mask),
|
||||
// and expose the state to window-level hotkey listeners — they see 'true'
|
||||
// during the unknown window as well, which is the point.
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.locked = locked ? 'true' : 'false'
|
||||
if (locked) setSettingsOpen(false)
|
||||
// Portals (antd modals, dropdowns, tooltips) attach to document.body,
|
||||
// outside the inert `#root` subtree, so a keyboard user could still Tab
|
||||
// into whatever happened to be open when the lock engaged. Inert every
|
||||
// other body child while locked; the overlay itself lives inside #root,
|
||||
// above the inert `.app-root`, and stays reachable.
|
||||
const appRoot = document.getElementById('root')
|
||||
const inerted: Element[] = []
|
||||
if (locked) {
|
||||
for (const el of Array.from(document.body.children)) {
|
||||
if (el === appRoot) continue
|
||||
try {
|
||||
el.setAttribute('inert', '')
|
||||
inerted.push(el)
|
||||
} catch {
|
||||
// a node that refuses the attribute must not break the lock
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
for (const el of inerted) el.removeAttribute('inert')
|
||||
}
|
||||
}, [locked])
|
||||
|
||||
// Interface language: t() reads the module-level language at render time, so
|
||||
// sync it before the tree renders — an effect would paint one frame late.
|
||||
// Silent on purpose: notifying subscribers during a render is what React
|
||||
@@ -71,12 +143,26 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={ANTD_LOCALES[language]}>
|
||||
<div className="app-root">
|
||||
<TitleBar />
|
||||
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<TransferPanel />
|
||||
</div>
|
||||
<>
|
||||
{/* Locked: the shell goes inert (no focus, no pointer, hidden from
|
||||
assistive tech) but stays mounted — unmounting it would kill the
|
||||
local/SSH sessions and the transfer list behind the overlay.
|
||||
`inert` is listed before aria-hidden so focus leaves the subtree
|
||||
before the browser checks for a focused descendant. */}
|
||||
<div className="app-root" inert={locked || undefined} aria-hidden={locked || undefined}>
|
||||
<TitleBar />
|
||||
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<TransferPanel />
|
||||
</div>
|
||||
{/* Unknown state paints the opaque layer alone: the shell stays hidden
|
||||
and no panel is shown, so startup cannot flash a lock screen. */}
|
||||
{lockState === null ? (
|
||||
<div className="lock-screen-boot" />
|
||||
) : locked ? (
|
||||
<LockScreen state={lockState} onStateChange={setLockState} />
|
||||
) : null}
|
||||
</>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { LockOutlined } from '@ant-design/icons'
|
||||
import { Button, Input } from 'antd'
|
||||
import type { InputRef } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import type { LockOperationError, LockSettingsState } from '@shared/ipc'
|
||||
import './lock.css'
|
||||
|
||||
/** Message key per failure, so every LockOperationError reads as its own line. */
|
||||
const ERROR_KEYS: Record<LockOperationError, string> = {
|
||||
'invalid-password': 'settings.lock.error.invalidPassword',
|
||||
'wrong-password': 'settings.lock.error.wrongPassword',
|
||||
cooldown: 'settings.lock.error.cooldown',
|
||||
'save-failed': 'settings.lock.error.saveFailed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure text for a `LockOperationResult.error`. A `cooldown` error is shown
|
||||
* with the remaining seconds when main reports them, and with the generic
|
||||
* wording otherwise — never as "retry in 0 seconds".
|
||||
*/
|
||||
export function lockErrorText(error: LockOperationError, cooldownMs?: number): string {
|
||||
if (error === 'cooldown') {
|
||||
const seconds = cooldownMs !== undefined && cooldownMs > 0 ? Math.ceil(cooldownMs / 1000) : 0
|
||||
return seconds > 0
|
||||
? t('settings.lock.error.cooldown', { n: seconds })
|
||||
: t('settings.lock.error.cooldownGeneric')
|
||||
}
|
||||
return t(ERROR_KEYS[error])
|
||||
}
|
||||
|
||||
export interface LockScreenProps {
|
||||
state: LockSettingsState
|
||||
/** publish the state main returned, so App drops the overlay once unlocked */
|
||||
onStateChange: (state: LockSettingsState) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock overlay for the main window.
|
||||
*
|
||||
* App.tsx renders it as a *sibling* of the app shell rather than inside it: the
|
||||
* shell stays mounted (and inert) underneath, so PTY/SSH sessions keep running
|
||||
* and the workspace is not torn down by a lock. The layer is opaque, so no
|
||||
* terminal output is visible through it.
|
||||
*
|
||||
* Deliberately minimal — no session content, no bypass button, and the password
|
||||
* never leaves this component: it is cleared after every attempt and is not
|
||||
* logged anywhere.
|
||||
*/
|
||||
export function LockScreen({ state, onStateChange }: LockScreenProps): React.JSX.Element {
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<LockOperationError | null>(null)
|
||||
/** local deadline, so the countdown keeps ticking between main's broadcasts */
|
||||
const [cooldownUntil, setCooldownUntil] = useState(0)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
|
||||
const cooldownMs = state.cooldownMs ?? 0
|
||||
useEffect(() => {
|
||||
setCooldownUntil(cooldownMs > 0 ? Date.now() + cooldownMs : 0)
|
||||
setNow(Date.now())
|
||||
}, [cooldownMs])
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownUntil <= 0) return
|
||||
const id = window.setInterval(() => setNow(Date.now()), 250)
|
||||
return () => window.clearInterval(id)
|
||||
}, [cooldownUntil])
|
||||
|
||||
const remainingMs = Math.max(0, cooldownUntil - now)
|
||||
const cooling = remainingMs > 0
|
||||
|
||||
// Focus follows usability: on mount and again when a cooldown runs out, so
|
||||
// the lock can be answered by typing without reaching for the mouse.
|
||||
useEffect(() => {
|
||||
if (!cooling) inputRef.current?.focus()
|
||||
}, [cooling])
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (busy || cooling || password.length === 0) return
|
||||
const attempt = password
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.api.unlockLock({ password: attempt })
|
||||
setPassword('')
|
||||
onStateChange(result.state)
|
||||
if (!result.ok) setError(result.error ?? 'wrong-password')
|
||||
} catch (err) {
|
||||
console.error('[lock] unlock request failed', err)
|
||||
setError('save-failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const message = cooling
|
||||
? lockErrorText('cooldown', remainingMs)
|
||||
: error
|
||||
? lockErrorText(error, cooldownMs)
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lock-screen"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('settings.lock.title')}
|
||||
// Modal focus trap: antd popovers/modals render outside the inert app
|
||||
// root, so Tab must not be allowed to walk into content hidden behind
|
||||
// this opaque overlay.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className="lock-screen-panel"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
<LockOutlined className="lock-screen-icon" />
|
||||
<div className="lock-screen-title">{t('settings.lock.title')}</div>
|
||||
<div className="lock-screen-desc">{t('settings.lock.screenDesc')}</div>
|
||||
<Input.Password
|
||||
ref={inputRef}
|
||||
className="lock-screen-input"
|
||||
size="large"
|
||||
value={password}
|
||||
disabled={cooling}
|
||||
visibilityToggle={false}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t('settings.lock.passwordPlaceholder')}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (error) setError(null)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="lock-screen-button"
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="submit"
|
||||
loading={busy}
|
||||
disabled={cooling || password.length === 0}
|
||||
>
|
||||
{busy ? t('settings.lock.unlocking') : t('settings.lock.unlock')}
|
||||
</Button>
|
||||
<div className="lock-screen-message" role="status">
|
||||
{message}
|
||||
</div>
|
||||
<div className="lock-screen-hint">{t('settings.lock.screenForgot')}</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/* ============================================================
|
||||
Lock overlay (lock/LockScreen.tsx)
|
||||
Opaque on purpose: nothing of the workspace underneath may
|
||||
show through. Colors come from the chrome variables, so the
|
||||
overlay follows the active terminal theme.
|
||||
============================================================ */
|
||||
|
||||
/* Opaque layer shared by the lock screen and the boot layer that App.tsx paints
|
||||
while the lock state is still unknown (first IPC round trip). Keeping one rule
|
||||
means the boot layer cannot drift away from the overlay's stacking level. */
|
||||
.lock-screen,
|
||||
.lock-screen-boot {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* above the workspace rail (960) and every antd layer: modal 1000,
|
||||
message 1010, notification 1050 */
|
||||
z-index: 4000;
|
||||
background: var(--chrome-bg-deep, #101418);
|
||||
color: var(--chrome-fg, #cccccc);
|
||||
}
|
||||
|
||||
/* Only the panel-bearing overlay centres anything; the boot layer is empty. */
|
||||
.lock-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lock-screen-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 340px;
|
||||
max-width: 80vw;
|
||||
padding: 30px 28px 22px;
|
||||
border: 1px solid var(--chrome-border, #2d2d2d);
|
||||
border-radius: 10px;
|
||||
background: var(--chrome-bg, #181818);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lock-screen-icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
color: var(--tab-accent, #3fb950);
|
||||
}
|
||||
|
||||
.lock-screen-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lock-screen-desc {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent);
|
||||
}
|
||||
|
||||
.lock-screen-input,
|
||||
.lock-screen-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Reserved height: the message appears and disappears without moving the
|
||||
button, so a failed attempt does not shift the input out from under the
|
||||
pointer. */
|
||||
.lock-screen-message {
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.lock-screen-hint {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 38%, transparent);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import ReactDOM from 'react-dom/client'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
|
||||
import type { AppApi } from '@shared/api'
|
||||
import type { LockSettingsState } from '@shared/ipc'
|
||||
import { DEFAULT_SETTINGS } from '@shared/settings'
|
||||
import { getThemeById } from '@shared/theme'
|
||||
import App from './App'
|
||||
@@ -19,7 +20,14 @@ import './global.css'
|
||||
setInterval(() => {
|
||||
const now = performance.now()
|
||||
const lag = now - lastTick - 2000
|
||||
if (lag > 3000) console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`)
|
||||
// Chromium throttles timers in a hidden page down to roughly one per
|
||||
// minute (intensive throttling), which this watchdog would otherwise
|
||||
// report as a ~58s "stall" on every tick while the window sits in the
|
||||
// tray. A hidden window also has nothing user-visible to freeze, so the
|
||||
// report is skipped until the page is visible again.
|
||||
if (lag > 3000 && !document.hidden) {
|
||||
console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`)
|
||||
}
|
||||
lastTick = now
|
||||
}, 2000)
|
||||
}
|
||||
@@ -29,6 +37,16 @@ import './global.css'
|
||||
if (typeof window !== 'undefined' && !window.api) {
|
||||
const noop = (): void => undefined
|
||||
const stubId = (): string => `stub-${Math.random().toString(36).slice(2)}`
|
||||
/** Browser stub: never configured and never locked, so the lock overlay
|
||||
* stays out of the way of layout work done in a plain vite page. */
|
||||
const stubLockState = (over?: Partial<LockSettingsState>): LockSettingsState => ({
|
||||
configured: false,
|
||||
enabled: false,
|
||||
autoLockMinutes: 0,
|
||||
lockAtStartup: false,
|
||||
locked: false,
|
||||
...over
|
||||
})
|
||||
const api: AppApi = {
|
||||
appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }),
|
||||
createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
|
||||
@@ -111,7 +129,16 @@ if (typeof window !== 'undefined' && !window.api) {
|
||||
getSessionState: async () => null,
|
||||
saveSessionState: async () => null,
|
||||
resolveCwd: async () => null,
|
||||
reportCwd: async () => null
|
||||
reportCwd: async () => null,
|
||||
getLockState: async () => stubLockState(),
|
||||
setLockPassword: async (input) => ({
|
||||
ok: true,
|
||||
state: stubLockState({ configured: (input.newPassword ?? '').length > 0 })
|
||||
}),
|
||||
clearLockPassword: async () => ({ ok: true, state: stubLockState() }),
|
||||
unlockLock: async () => ({ ok: true, state: stubLockState() }),
|
||||
lockNow: async () => stubLockState(),
|
||||
onLockStateChanged: () => noop
|
||||
}
|
||||
;(window as unknown as { api: AppApi }).api = api
|
||||
}
|
||||
@@ -130,6 +157,15 @@ function FontHotkeyListener(): null {
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
// The lock overlay leaves Workspace mounted; window-capture hotkeys must
|
||||
// not mutate font size on a shell hidden behind it. preventDefault matters
|
||||
// here: returning alone lets the chord reach Electron's default menu
|
||||
// accelerators (zoomIn/zoomOut/resetZoom), which rescale the whole UI
|
||||
// behind the opaque mask and make Chromium persist that zoom per origin.
|
||||
if (document.documentElement.dataset.locked === 'true') {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!e.ctrlKey || e.metaKey) return
|
||||
const target = e.target as HTMLElement | null
|
||||
const isXtermHelper =
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Input, Popconfirm, Select, Switch, Tag } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import type { LockOperationResult, LockSettingsState } from '@shared/ipc'
|
||||
import { LOCK_AUTO_DELAYS, type LockAutoDelay } from '@shared/settings'
|
||||
import { lockErrorText } from '@renderer/lock/LockScreen'
|
||||
import { SettingRow } from './fields'
|
||||
import { useSettingsStore } from './store'
|
||||
|
||||
type Feedback = { kind: 'ok' | 'error'; text: string } | null
|
||||
|
||||
/**
|
||||
* 锁屏设置页。
|
||||
*
|
||||
* Two sources, on purpose: the switches live in `settings.lock` (persisted
|
||||
* through the settings store, so they follow the normal save/rollback path),
|
||||
* while "is a password configured / is the screen locked right now" comes from
|
||||
* main (`lock.json` holds the verifier, never settings). The password fields
|
||||
* are write-only: they are sent once and cleared, main never echoes them back.
|
||||
*/
|
||||
export function LockSettingsTab(): React.JSX.Element {
|
||||
const lock = useSettingsStore((s) => s.settings.lock)
|
||||
const updateLock = useSettingsStore((s) => s.updateLock)
|
||||
const [lockState, setLockState] = useState<LockSettingsState | null>(null)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [feedback, setFeedback] = useState<Feedback>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
void window.api.getLockState().then(
|
||||
(state: LockSettingsState) => {
|
||||
if (alive) setLockState(state)
|
||||
},
|
||||
(err: unknown) => console.error('[lock] getLockState failed', err)
|
||||
)
|
||||
// Keeps `configured` honest when the lock engages or main clears the
|
||||
// verifier (e.g. an unlock attempt failed and a cooldown started).
|
||||
const off = window.api.onLockStateChanged((state: LockSettingsState) => setLockState(state))
|
||||
return () => {
|
||||
alive = false
|
||||
off()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const configured = lockState?.configured === true
|
||||
const usable = configured && lock.enabled
|
||||
|
||||
const clearFields = (): void => {
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
}
|
||||
|
||||
/** Run a lock operation and fold its result into the local state + feedback. */
|
||||
const run = async (op: () => Promise<LockOperationResult>, okText: string): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFeedback(null)
|
||||
try {
|
||||
const result = await op()
|
||||
setLockState(result.state)
|
||||
if (result.ok) {
|
||||
clearFields()
|
||||
setFeedback({ kind: 'ok', text: okText })
|
||||
} else {
|
||||
setFeedback({
|
||||
kind: 'error',
|
||||
text: lockErrorText(result.error ?? 'save-failed', result.state.cooldownMs)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[lock] operation failed', err)
|
||||
setFeedback({ kind: 'error', text: lockErrorText('save-failed') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePassword = (): void => {
|
||||
if (newPassword.length === 0) {
|
||||
setFeedback({ kind: 'error', text: t('settings.lock.password.empty') })
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setFeedback({ kind: 'error', text: t('settings.lock.password.mismatch') })
|
||||
return
|
||||
}
|
||||
void run(
|
||||
() =>
|
||||
window.api.setLockPassword(configured ? { currentPassword, newPassword } : { newPassword }),
|
||||
t('settings.lock.password.saved')
|
||||
)
|
||||
}
|
||||
|
||||
const clearPassword = (): void => {
|
||||
if (currentPassword.length === 0) {
|
||||
setFeedback({ kind: 'error', text: t('settings.lock.password.empty') })
|
||||
return
|
||||
}
|
||||
void run(
|
||||
() => window.api.clearLockPassword({ currentPassword }),
|
||||
t('settings.lock.password.cleared')
|
||||
)
|
||||
}
|
||||
|
||||
/** lockNow answers with the state directly, not with an operation result. */
|
||||
const lockNow = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFeedback(null)
|
||||
try {
|
||||
setLockState(await window.api.lockNow())
|
||||
} catch (err) {
|
||||
console.error('[lock] lockNow failed', err)
|
||||
setFeedback({ kind: 'error', text: lockErrorText('save-failed') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const autoLockOptions = LOCK_AUTO_DELAYS.map((minutes: LockAutoDelay) => ({
|
||||
value: minutes,
|
||||
label:
|
||||
minutes === 0
|
||||
? t('settings.lock.autoLockOff')
|
||||
: t('settings.lock.autoLockMinutes', { n: minutes })
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
<div className="settings-block-label">
|
||||
{t('settings.lock.password.title')}
|
||||
<span className="settings-block-hint">{t('settings.lock.password.desc')}</span>
|
||||
<Tag color={configured ? 'green' : 'default'}>
|
||||
{configured
|
||||
? t('settings.lock.password.configured')
|
||||
: t('settings.lock.password.notConfigured')}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{configured && (
|
||||
<SettingRow
|
||||
label={t('settings.lock.password.current')}
|
||||
control={
|
||||
<Input.Password
|
||||
className="settings-lock-input"
|
||||
value={currentPassword}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t('settings.lock.password.currentPlaceholder')}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t('settings.lock.password.new')}
|
||||
control={
|
||||
<Input.Password
|
||||
className="settings-lock-input"
|
||||
value={newPassword}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t('settings.lock.password.newPlaceholder')}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('settings.lock.password.confirm')}
|
||||
control={
|
||||
<Input.Password
|
||||
className="settings-lock-input"
|
||||
value={confirmPassword}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t('settings.lock.password.confirmPlaceholder')}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="settings-lock-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
loading={busy}
|
||||
disabled={newPassword.length === 0 || confirmPassword.length === 0}
|
||||
onClick={savePassword}
|
||||
>
|
||||
{configured ? t('settings.lock.password.change') : t('settings.lock.password.set')}
|
||||
</Button>
|
||||
{configured && (
|
||||
<Popconfirm
|
||||
title={t('settings.lock.password.clearTitle')}
|
||||
description={t('settings.lock.password.clearDesc')}
|
||||
okText={t('settings.lock.password.clear')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={clearPassword}
|
||||
>
|
||||
<Button danger disabled={busy}>
|
||||
{t('settings.lock.password.clear')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={'settings-lock-feedback' + (feedback?.kind === 'error' ? ' is-error' : ' is-ok')}
|
||||
role="status"
|
||||
>
|
||||
{feedback?.text ?? ''}
|
||||
</div>
|
||||
<div className="settings-lock-note">{t('settings.lock.password.forgot')}</div>
|
||||
|
||||
<SettingRow
|
||||
label={t('settings.lock.enabled')}
|
||||
desc={configured ? t('settings.lock.enabledDesc') : t('settings.lock.needPassword')}
|
||||
control={
|
||||
<Switch
|
||||
checked={lock.enabled}
|
||||
disabled={!configured}
|
||||
onChange={(checked) => void updateLock({ enabled: checked })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('settings.lock.autoLock')}
|
||||
desc={t('settings.lock.autoLockDesc')}
|
||||
control={
|
||||
<Select
|
||||
className="settings-select"
|
||||
value={lock.autoLockMinutes}
|
||||
disabled={!usable}
|
||||
onChange={(value) => void updateLock({ autoLockMinutes: value })}
|
||||
options={autoLockOptions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('settings.lock.lockAtStartup')}
|
||||
desc={t('settings.lock.lockAtStartupDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={lock.lockAtStartup}
|
||||
disabled={!usable}
|
||||
onChange={(checked) => void updateLock({ lockAtStartup: checked })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('settings.lock.lockNow')}
|
||||
desc={t('settings.lock.lockNowDesc')}
|
||||
control={
|
||||
<Button disabled={!configured || busy} onClick={() => void lockNow()}>
|
||||
{t('settings.lock.lockNow')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useSettingsStore } from './store'
|
||||
import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs'
|
||||
import { ThemeSettingsTab } from './ThemeSettingsTab'
|
||||
import { HighlightTab } from './HighlightTab'
|
||||
import { LockSettingsTab } from './LockSettingsTab'
|
||||
import { AboutTab } from './AboutTab'
|
||||
import { ThemeEditor } from '../theme/editor/ThemeEditor'
|
||||
import './settings.css'
|
||||
@@ -91,6 +92,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
)
|
||||
},
|
||||
{ key: 'system', label: t('settings.tabs.system'), children: <SystemSettingsTab /> },
|
||||
{ key: 'lock', label: t('settings.tabs.lock'), children: <LockSettingsTab /> },
|
||||
{ key: 'about', label: t('settings.tabs.about'), children: <AboutTab /> }
|
||||
]
|
||||
|
||||
|
||||
@@ -571,6 +571,43 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------- lock tab (settings/LockSettingsTab.tsx) ---------- */
|
||||
|
||||
.settings-lock-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.settings-lock-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin: 8px 8px 0;
|
||||
}
|
||||
|
||||
/* Reserved height, so a feedback line appearing does not push the rows below
|
||||
it around. */
|
||||
.settings-lock-feedback {
|
||||
min-height: 18px;
|
||||
margin: 6px 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-lock-feedback.is-ok {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.settings-lock-feedback.is-error {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.settings-lock-note {
|
||||
margin: 4px 8px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ---------- shortcut recorder (press-to-record input) ---------- */
|
||||
|
||||
.shortcut-input {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppSettings, SystemSettings, TerminalSettings } from '@shared/settings'
|
||||
import type { AppSettings, LockSettings, SystemSettings, TerminalSettings } from '@shared/settings'
|
||||
import { DEFAULT_SETTINGS } from '@shared/settings'
|
||||
import type { TerminalTheme } from '@shared/theme'
|
||||
import { getThemeById } from '@shared/theme'
|
||||
@@ -19,6 +19,8 @@ export interface SettingsState {
|
||||
setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise<void>
|
||||
/** shallow-merge into settings.system and persist */
|
||||
updateSystem: (partial: Partial<SystemSettings>) => Promise<void>
|
||||
/** shallow-merge into settings.lock and persist */
|
||||
updateLock: (partial: Partial<LockSettings>) => Promise<void>
|
||||
}
|
||||
|
||||
/** unsubscriber for the cross-window SETTINGS_CHANGED listener; held module-level so hydrate() is idempotent */
|
||||
@@ -68,6 +70,8 @@ async function persist(
|
||||
if (terminal) patch.terminal = terminal as TerminalSettings
|
||||
const system = diffGroup(prev.system, written.system)
|
||||
if (system) patch.system = system as SystemSettings
|
||||
const lock = diffGroup(prev.lock, written.lock)
|
||||
if (lock) patch.lock = lock as LockSettings
|
||||
await window.api.saveSettings(patch)
|
||||
} catch (err) {
|
||||
console.error(`[settings] ${label} failed, rolling back`, err)
|
||||
@@ -131,6 +135,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
const prev = get().settings
|
||||
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
|
||||
await persist(get, set, next, prev, 'updateSystem')
|
||||
},
|
||||
|
||||
updateLock: async (partial) => {
|
||||
const prev = get().settings
|
||||
const next: AppSettings = { ...prev, lock: { ...prev.lock, ...partial } }
|
||||
await persist(get, set, next, prev, 'updateLock')
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Input, Modal } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import type { SshConnection, SshSecretOverride } from '@shared/connections'
|
||||
import { connectPromptFor, type SshConnection, type SshSecretOverride } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
/**
|
||||
* Connect-time gateways for one SSH connection attempt.
|
||||
*
|
||||
* `phase` drives which dialog shows:
|
||||
* - 'secret' → secret prompt modal (password when `askPasswordAtConnect`,
|
||||
* passphrase when `askPassphraseAtConnect`). This is the only
|
||||
* connect-time dialog that can be cancelled, because
|
||||
* - 'secret' → secret prompt modal (kind chosen by the shared
|
||||
* `connectPromptFor`: password for ask-at-connect password
|
||||
* auth, passphrase for ask-at-connect key auth). This is the
|
||||
* only connect-time dialog that can be cancelled, because
|
||||
* openSession cannot be aborted before it resolves.
|
||||
* - 'connecting' → an uncancellable "连接中…" modal while openSession flies.
|
||||
*
|
||||
@@ -34,14 +35,15 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
|
||||
const [password, setPassword] = useState('')
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
|
||||
const prompt = conn != null && phase !== 'connecting' ? connectPromptFor(conn) : null
|
||||
const stage: ConnectStage =
|
||||
conn == null
|
||||
? 'idle'
|
||||
: phase === 'connecting'
|
||||
? 'connecting'
|
||||
: conn.askPasswordAtConnect
|
||||
: prompt === 'password'
|
||||
? 'password'
|
||||
: conn.askPassphraseAtConnect
|
||||
: prompt === 'passphrase'
|
||||
? 'passphrase'
|
||||
: 'connecting'
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
} from 'dockview-react'
|
||||
import 'dockview-react/dist/styles/dockview.css'
|
||||
|
||||
import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections'
|
||||
import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
|
||||
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
|
||||
@@ -660,6 +660,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
// Tab cycling must not move an invisible workspace while the lock
|
||||
// overlay covers the main window.
|
||||
if (document.documentElement.dataset.locked === 'true') return
|
||||
const ctrl = e.ctrlKey
|
||||
const code = e.code
|
||||
if (!ctrl || (code !== 'PageUp' && code !== 'PageDown')) return
|
||||
@@ -764,12 +767,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
const handleConnectRequest = useCallback(
|
||||
(conn: SshConnection): void => {
|
||||
if (connectInFlightRef.current > 0) return
|
||||
// Ask-at-connect connections go through the secret prompt first; saved
|
||||
// credentials must connect IMMEDIATELY — routing them through ConnectFlow
|
||||
// would only ever *render* a "connecting" spinner without firing openSession.
|
||||
const needsPassword = conn.auth === 'password' && conn.askPasswordAtConnect
|
||||
const needsPassphrase = conn.auth === 'privateKey' && conn.askPassphraseAtConnect
|
||||
if (needsPassword || needsPassphrase) {
|
||||
// Ask-at-connect connections go through the secret prompt first (the
|
||||
// prompt kind comes from the shared `connectPromptFor`, so a stale ask
|
||||
// flag from a switched auth method can never pop the wrong dialog);
|
||||
// saved credentials must connect IMMEDIATELY — routing them through
|
||||
// ConnectFlow would only ever *render* a "connecting" spinner without
|
||||
// firing openSession.
|
||||
const prompt = connectPromptFor(conn)
|
||||
if (prompt !== null) {
|
||||
setRequestedConn(conn)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from './ipc'
|
||||
import type { AppSettings } from './settings'
|
||||
import type { ReleaseNote, UpdateState } from './ipc'
|
||||
import type { LockOperationResult, LockPasswordInput, LockSettingsState } from './ipc'
|
||||
import type {
|
||||
HostKeyAction,
|
||||
HostKeyPromptEvent,
|
||||
@@ -100,6 +101,17 @@ export interface AppApi {
|
||||
saveSettings(next: Partial<AppSettings>): Promise<AppSettings>
|
||||
onSettingsChanged(cb: (s: AppSettings) => void): () => void
|
||||
|
||||
// ---- lock screen (main-window overlay; main owns the lock state) ----
|
||||
getLockState(): Promise<LockSettingsState>
|
||||
/** set or replace the password; verifies currentPassword when one exists */
|
||||
setLockPassword(input: LockPasswordInput): Promise<LockOperationResult>
|
||||
/** remove the password; verifies currentPassword when one exists */
|
||||
clearLockPassword(input: { currentPassword?: string }): Promise<LockOperationResult>
|
||||
/** answer the lock screen; resets the failure cooldown on success */
|
||||
unlockLock(input: { password?: string }): Promise<LockOperationResult>
|
||||
lockNow(): Promise<LockSettingsState>
|
||||
onLockStateChanged(cb: (state: LockSettingsState) => void): () => void
|
||||
|
||||
// ---- fonts ----
|
||||
listFonts(): Promise<string[]>
|
||||
|
||||
|
||||
@@ -67,6 +67,28 @@ export interface SshSecretOverride {
|
||||
passphrase?: string
|
||||
}
|
||||
|
||||
/** The secret kind a connection must prompt for before connecting. */
|
||||
export type ConnectPromptKind = 'password' | 'passphrase'
|
||||
|
||||
/**
|
||||
* Which secret dialog (if any) a connect attempt for `conn` must show first.
|
||||
*
|
||||
* Single source of truth for Workspace's `handleConnectRequest` and
|
||||
* ConnectFlow's stage selection: both used to derive it independently and the
|
||||
* renderer-only check on ConnectFlow's side ignored `auth`, so a key-auth
|
||||
* bookmark with a stale `askPasswordAtConnect` flag (left over from an edit
|
||||
* that switched auth methods) popped a password dialog.
|
||||
*
|
||||
* Each ask* flag only counts for the auth method it belongs to; `null` means
|
||||
* saved credentials exist and the connection must start immediately (existing
|
||||
* UX — such connections must never route through the secret prompt).
|
||||
*/
|
||||
export function connectPromptFor(conn: SshConnection): ConnectPromptKind | null {
|
||||
if (conn.auth === 'password' && conn.askPasswordAtConnect) return 'password'
|
||||
if (conn.auth === 'privateKey' && conn.askPassphraseAtConnect) return 'passphrase'
|
||||
return null
|
||||
}
|
||||
|
||||
export interface SessionOpenOptions {
|
||||
kind: 'local' | 'ssh'
|
||||
/** ssh only */
|
||||
|
||||
@@ -17,6 +17,7 @@ const main: Record<string, string> = {
|
||||
|
||||
'main.ssh.connectTimeout': 'Connection timed out ({host}:{port})',
|
||||
'main.ssh.saveFingerprintFailed': 'Failed to save the host fingerprint: {detail}',
|
||||
'main.ssh.knownHostsUnreadable': 'The known-hosts file (ssh_known_hosts.json) exists but could not be read. The connection was refused to protect the pinned fingerprints. Repair or delete the file and try again.',
|
||||
'main.ssh.hostKeyRejected': 'The user rejected the host key',
|
||||
'main.ssh.connectFailed': 'Connection failed {host}:{port}: {detail}',
|
||||
'main.ssh.shellOpenFailed': 'Could not open the SSH shell ({host}:{port}): {detail}',
|
||||
|
||||
@@ -7,6 +7,7 @@ const settings: Record<string, string> = {
|
||||
'settings.tabs.highlight': 'Highlighting',
|
||||
'settings.tabs.theme': 'Themes',
|
||||
'settings.tabs.system': 'System',
|
||||
'settings.tabs.lock': 'Lock',
|
||||
'settings.tabs.about': 'About',
|
||||
'settings.resizeHandle': 'Drag to resize',
|
||||
'settings.preview': 'Preview',
|
||||
@@ -100,6 +101,52 @@ const settings: Record<string, string> = {
|
||||
'settings.system.shortcutPlaceholder': 'Click and press a shortcut, leave empty to disable',
|
||||
'settings.system.shortcutConflict':
|
||||
'This shortcut is already used by the app (Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn). Please choose another.',
|
||||
'settings.lock.password.title': 'Lock password',
|
||||
'settings.lock.password.desc': 'A password is required before the lock can be enabled',
|
||||
'settings.lock.password.configured': 'Set',
|
||||
'settings.lock.password.notConfigured': 'Not set',
|
||||
'settings.lock.password.current': 'Current password',
|
||||
'settings.lock.password.currentPlaceholder': 'Enter the current password',
|
||||
'settings.lock.password.new': 'New password',
|
||||
'settings.lock.password.newPlaceholder': 'Enter a new password',
|
||||
'settings.lock.password.confirm': 'Confirm new password',
|
||||
'settings.lock.password.confirmPlaceholder': 'Repeat the new password',
|
||||
'settings.lock.password.set': 'Set password',
|
||||
'settings.lock.password.change': 'Change password',
|
||||
'settings.lock.password.clear': 'Remove password',
|
||||
'settings.lock.password.clearTitle': 'Remove the lock password?',
|
||||
'settings.lock.password.clearDesc':
|
||||
'The lock is turned off along with it. The current password is required to confirm.',
|
||||
'settings.lock.password.mismatch': 'The two new passwords do not match',
|
||||
'settings.lock.password.empty': 'Enter a password',
|
||||
'settings.lock.password.saved': 'Password saved',
|
||||
'settings.lock.password.cleared': 'Lock password removed',
|
||||
'settings.lock.password.forgot':
|
||||
'A forgotten lock password cannot be recovered from any cloud or backdoor: the only way back in is deleting this machine\u2019s lock data and setting a new one.',
|
||||
'settings.lock.enabled': 'Enable lock',
|
||||
'settings.lock.enabledDesc':
|
||||
'Lock the main window when idle or at startup; unlocking needs the password above',
|
||||
'settings.lock.needPassword': 'Set a lock password first',
|
||||
'settings.lock.autoLock': 'Lock when idle',
|
||||
'settings.lock.autoLockDesc': 'Lock once the system has been idle this long (0 = never)',
|
||||
'settings.lock.autoLockOff': 'Off',
|
||||
'settings.lock.autoLockMinutes': '{n} min',
|
||||
'settings.lock.lockAtStartup': 'Lock at startup',
|
||||
'settings.lock.lockAtStartupDesc': 'Ask for the password right after every launch',
|
||||
'settings.lock.lockNow': 'Lock now',
|
||||
'settings.lock.lockNowDesc': 'Lock the main window immediately (sessions keep running)',
|
||||
'settings.lock.title': 'Locked',
|
||||
'settings.lock.screenDesc': 'Enter the lock password to unlock',
|
||||
'settings.lock.passwordPlaceholder': 'Password',
|
||||
'settings.lock.unlock': 'Unlock',
|
||||
'settings.lock.unlocking': 'Unlocking…',
|
||||
'settings.lock.screenForgot':
|
||||
'Forgot it? The lock password cannot be recovered \u2014 the only way out is deleting this machine\u2019s lock data.',
|
||||
'settings.lock.error.invalidPassword': 'Password is invalid (empty or too short)',
|
||||
'settings.lock.error.wrongPassword': 'Wrong password',
|
||||
'settings.lock.error.cooldown': 'Too many attempts \u2014 try again in {n}s',
|
||||
'settings.lock.error.cooldownGeneric': 'Too many attempts \u2014 try again later',
|
||||
'settings.lock.error.saveFailed': 'Save failed, please retry',
|
||||
'settings.resetTerminal': 'Reset terminal settings',
|
||||
'settings.resetTerminalTitle': 'Reset terminal settings?',
|
||||
'settings.resetTerminalDesc': 'All terminal settings — font, cursor, rendering and theme — will be restored to defaults.',
|
||||
|
||||
@@ -17,6 +17,7 @@ const main: Record<string, string> = {
|
||||
|
||||
'main.ssh.connectTimeout': '接続がタイムアウトしました ({host}:{port})',
|
||||
'main.ssh.saveFingerprintFailed': 'ホスト鍵のフィンガープリントを保存できませんでした: {detail}',
|
||||
'main.ssh.knownHostsUnreadable': '既知ホストファイル (ssh_known_hosts.json) が存在しますが読み取れないため、保存済みフィンガープリントを保護するために接続を拒否しました。ファイルを修復または削除してから再試行してください。',
|
||||
'main.ssh.hostKeyRejected': 'ユーザーがホスト鍵を拒否しました',
|
||||
'main.ssh.connectFailed': '接続に失敗しました {host}:{port}: {detail}',
|
||||
'main.ssh.shellOpenFailed': 'SSH シェルを開けません ({host}:{port}): {detail}',
|
||||
|
||||
@@ -7,6 +7,7 @@ const settings: Record<string, string> = {
|
||||
'settings.tabs.highlight': 'ハイライト',
|
||||
'settings.tabs.theme': 'テーマ',
|
||||
'settings.tabs.system': 'システム',
|
||||
'settings.tabs.lock': 'ロック',
|
||||
'settings.tabs.about': 'このアプリについて',
|
||||
'settings.resizeHandle': 'ドラッグしてサイズ変更',
|
||||
'settings.preview': 'プレビュー',
|
||||
@@ -97,6 +98,52 @@ const settings: Record<string, string> = {
|
||||
'settings.system.shortcutPlaceholder': 'クリックしてショートカットを押す。空欄で無効',
|
||||
'settings.system.shortcutConflict':
|
||||
'このショートカットはアプリ内で既に使用されています(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn)。別のものを選んでください。',
|
||||
'settings.lock.password.title': 'ロックパスワード',
|
||||
'settings.lock.password.desc': 'パスワードを設定するとロックを有効にできます',
|
||||
'settings.lock.password.configured': '設定済み',
|
||||
'settings.lock.password.notConfigured': '未設定',
|
||||
'settings.lock.password.current': '現在のパスワード',
|
||||
'settings.lock.password.currentPlaceholder': '現在のパスワードを入力',
|
||||
'settings.lock.password.new': '新しいパスワード',
|
||||
'settings.lock.password.newPlaceholder': '新しいパスワードを入力',
|
||||
'settings.lock.password.confirm': '新しいパスワード(確認)',
|
||||
'settings.lock.password.confirmPlaceholder': 'もう一度入力してください',
|
||||
'settings.lock.password.set': 'パスワードを設定',
|
||||
'settings.lock.password.change': 'パスワードを変更',
|
||||
'settings.lock.password.clear': 'パスワードを削除',
|
||||
'settings.lock.password.clearTitle': 'ロックパスワードを削除しますか?',
|
||||
'settings.lock.password.clearDesc':
|
||||
'削除するとロックも無効になります。確認のため現在のパスワードが必要です。',
|
||||
'settings.lock.password.mismatch': '新しいパスワードが一致しません',
|
||||
'settings.lock.password.empty': 'パスワードを入力してください',
|
||||
'settings.lock.password.saved': 'パスワードを保存しました',
|
||||
'settings.lock.password.cleared': 'ロックパスワードを削除しました',
|
||||
'settings.lock.password.forgot':
|
||||
'パスワードを忘れてもクラウドから復元する方法はなく、回避手段もありません。本機のロックデータを削除して設定し直すしかありません。',
|
||||
'settings.lock.enabled': 'ロックを有効にする',
|
||||
'settings.lock.enabledDesc':
|
||||
'アイドル時と起動時にメインウィンドウをロックします。解除には上のパスワードが必要です',
|
||||
'settings.lock.needPassword': '先にロックパスワードを設定してください',
|
||||
'settings.lock.autoLock': 'アイドル時に自動ロック',
|
||||
'settings.lock.autoLockDesc': 'システムがこの時間アイドル状態になったらロックします(0 は無効)',
|
||||
'settings.lock.autoLockOff': '無効',
|
||||
'settings.lock.autoLockMinutes': '{n} 分',
|
||||
'settings.lock.lockAtStartup': '起動時にロック',
|
||||
'settings.lock.lockAtStartupDesc': '起動直後にパスワードの入力を求めます',
|
||||
'settings.lock.lockNow': '今すぐロック',
|
||||
'settings.lock.lockNowDesc': 'メインウィンドウをすぐにロックします(セッションは動作を続けます)',
|
||||
'settings.lock.title': 'ロック中',
|
||||
'settings.lock.screenDesc': 'ロックパスワードを入力して解除します',
|
||||
'settings.lock.passwordPlaceholder': 'パスワード',
|
||||
'settings.lock.unlock': '解除',
|
||||
'settings.lock.unlocking': '解除中…',
|
||||
'settings.lock.screenForgot':
|
||||
'パスワードを忘れた場合、復元する方法はありません。本機のロックデータを削除するしかありません。',
|
||||
'settings.lock.error.invalidPassword': 'パスワードが無効です(空または短すぎます)',
|
||||
'settings.lock.error.wrongPassword': 'パスワードが違います',
|
||||
'settings.lock.error.cooldown': '試行回数が多すぎます。{n} 秒後にもう一度お試しください',
|
||||
'settings.lock.error.cooldownGeneric': '試行回数が多すぎます。しばらくしてからお試しください',
|
||||
'settings.lock.error.saveFailed': '保存に失敗しました。もう一度お試しください',
|
||||
'settings.resetTerminal': 'ターミナル設定をリセット',
|
||||
'settings.resetTerminalTitle': 'ターミナル設定をリセットしますか?',
|
||||
'settings.resetTerminalDesc': 'フォント、カーソル、レンダリング、テーマなどすべてのターミナル設定がデフォルトに戻ります。',
|
||||
|
||||
@@ -17,6 +17,7 @@ const main: Record<string, string> = {
|
||||
|
||||
'main.ssh.connectTimeout': '连接超时 ({host}:{port})',
|
||||
'main.ssh.saveFingerprintFailed': '保存主机指纹失败: {detail}',
|
||||
'main.ssh.knownHostsUnreadable': '已知主机文件 (ssh_known_hosts.json) 存在但无法读取,为避免覆盖已保存的指纹,本次连接被拒绝。请修复或删除该文件后重试。',
|
||||
'main.ssh.hostKeyRejected': '用户拒绝了主机指纹',
|
||||
'main.ssh.connectFailed': '连接失败 {host}:{port}: {detail}',
|
||||
'main.ssh.shellOpenFailed': '无法打开 SSH shell ({host}:{port}): {detail}',
|
||||
|
||||
@@ -7,6 +7,7 @@ const settings: Record<string, string> = {
|
||||
'settings.tabs.highlight': '高亮',
|
||||
'settings.tabs.theme': '主题',
|
||||
'settings.tabs.system': '系统',
|
||||
'settings.tabs.lock': '锁屏',
|
||||
'settings.tabs.about': '关于',
|
||||
'settings.resizeHandle': '拖拽调整大小',
|
||||
'settings.preview': '预览',
|
||||
@@ -94,6 +95,49 @@ const settings: Record<string, string> = {
|
||||
'settings.system.shortcutPlaceholder': '点击后按下快捷键,留空禁用',
|
||||
'settings.system.shortcutConflict':
|
||||
'该组合键已被应用内快捷键占用(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn),请换一个。',
|
||||
'settings.lock.password.title': '锁屏密码',
|
||||
'settings.lock.password.desc': '设置密码后才能启用锁屏',
|
||||
'settings.lock.password.configured': '已配置',
|
||||
'settings.lock.password.notConfigured': '未配置',
|
||||
'settings.lock.password.current': '当前密码',
|
||||
'settings.lock.password.currentPlaceholder': '请输入当前密码',
|
||||
'settings.lock.password.new': '新密码',
|
||||
'settings.lock.password.newPlaceholder': '请输入新密码',
|
||||
'settings.lock.password.confirm': '确认新密码',
|
||||
'settings.lock.password.confirmPlaceholder': '再次输入新密码',
|
||||
'settings.lock.password.set': '设置密码',
|
||||
'settings.lock.password.change': '修改密码',
|
||||
'settings.lock.password.clear': '清除密码',
|
||||
'settings.lock.password.clearTitle': '清除锁屏密码?',
|
||||
'settings.lock.password.clearDesc': '清除后锁屏会一并关闭,需要当前密码确认。',
|
||||
'settings.lock.password.mismatch': '两次输入的新密码不一致',
|
||||
'settings.lock.password.empty': '请填写密码',
|
||||
'settings.lock.password.saved': '密码已保存',
|
||||
'settings.lock.password.cleared': '锁屏密码已清除',
|
||||
'settings.lock.password.forgot':
|
||||
'忘记锁屏密码无法通过云端找回,也没有后门:只能删除本机锁屏数据后重新设置。',
|
||||
'settings.lock.enabled': '启用锁屏',
|
||||
'settings.lock.enabledDesc': '闲置或启动时锁定主窗口,解锁需要上面的密码',
|
||||
'settings.lock.needPassword': '请先设置锁屏密码',
|
||||
'settings.lock.autoLock': '闲置自动锁屏',
|
||||
'settings.lock.autoLockDesc': '系统闲置超过该时长后自动锁定(0 表示从不)',
|
||||
'settings.lock.autoLockOff': '关闭',
|
||||
'settings.lock.autoLockMinutes': '{n} 分钟',
|
||||
'settings.lock.lockAtStartup': '启动时锁屏',
|
||||
'settings.lock.lockAtStartupDesc': '每次启动后先要求输入密码',
|
||||
'settings.lock.lockNow': '立即锁屏',
|
||||
'settings.lock.lockNowDesc': '马上锁定主窗口(会话保持运行)',
|
||||
'settings.lock.title': '已锁定',
|
||||
'settings.lock.screenDesc': '输入锁屏密码解锁',
|
||||
'settings.lock.passwordPlaceholder': '密码',
|
||||
'settings.lock.unlock': '解锁',
|
||||
'settings.lock.unlocking': '解锁中…',
|
||||
'settings.lock.screenForgot': '忘记密码?锁屏密码无法找回,只能删除本机锁屏数据。',
|
||||
'settings.lock.error.invalidPassword': '密码无效(不能为空或过短)',
|
||||
'settings.lock.error.wrongPassword': '密码错误',
|
||||
'settings.lock.error.cooldown': '尝试次数过多,请等待 {n} 秒后重试',
|
||||
'settings.lock.error.cooldownGeneric': '尝试次数过多,请稍后再试',
|
||||
'settings.lock.error.saveFailed': '保存失败,请重试',
|
||||
'settings.resetTerminal': '恢复默认终端设置',
|
||||
'settings.resetTerminalTitle': '恢复默认终端设置?',
|
||||
'settings.resetTerminalDesc': '字体、光标、渲染与主题等全部终端设置将恢复为默认值。',
|
||||
|
||||
@@ -17,6 +17,7 @@ const main: Record<string, string> = {
|
||||
|
||||
'main.ssh.connectTimeout': '連線逾時 ({host}:{port})',
|
||||
'main.ssh.saveFingerprintFailed': '儲存主機指紋失敗: {detail}',
|
||||
'main.ssh.knownHostsUnreadable': '已知主機檔案 (ssh_known_hosts.json) 存在但無法讀取,為避免覆寫已儲存的指紋,本次連線被拒絕。請修復或刪除該檔案後重試。',
|
||||
'main.ssh.hostKeyRejected': '使用者拒絕了主機指紋',
|
||||
'main.ssh.connectFailed': '連線失敗 {host}:{port}: {detail}',
|
||||
'main.ssh.shellOpenFailed': '無法開啟 SSH shell ({host}:{port}): {detail}',
|
||||
|
||||
@@ -7,6 +7,7 @@ const settings: Record<string, string> = {
|
||||
'settings.tabs.highlight': '高亮',
|
||||
'settings.tabs.theme': '主題',
|
||||
'settings.tabs.system': '系統',
|
||||
'settings.tabs.lock': '鎖定畫面',
|
||||
'settings.tabs.about': '關於',
|
||||
'settings.resizeHandle': '拖曳調整大小',
|
||||
'settings.preview': '預覽',
|
||||
@@ -94,6 +95,49 @@ const settings: Record<string, string> = {
|
||||
'settings.system.shortcutPlaceholder': '點擊後按下快速鍵,留空為停用',
|
||||
'settings.system.shortcutConflict':
|
||||
'此組合鍵已被應用內快捷鍵佔用(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn),請換一個。',
|
||||
'settings.lock.password.title': '鎖定密碼',
|
||||
'settings.lock.password.desc': '設定密碼後才能啟用鎖定',
|
||||
'settings.lock.password.configured': '已設定',
|
||||
'settings.lock.password.notConfigured': '未設定',
|
||||
'settings.lock.password.current': '目前密碼',
|
||||
'settings.lock.password.currentPlaceholder': '請輸入目前密碼',
|
||||
'settings.lock.password.new': '新密碼',
|
||||
'settings.lock.password.newPlaceholder': '請輸入新密碼',
|
||||
'settings.lock.password.confirm': '確認新密碼',
|
||||
'settings.lock.password.confirmPlaceholder': '再次輸入新密碼',
|
||||
'settings.lock.password.set': '設定密碼',
|
||||
'settings.lock.password.change': '變更密碼',
|
||||
'settings.lock.password.clear': '清除密碼',
|
||||
'settings.lock.password.clearTitle': '清除鎖定密碼?',
|
||||
'settings.lock.password.clearDesc': '清除後鎖定會一併關閉,需要目前密碼確認。',
|
||||
'settings.lock.password.mismatch': '兩次輸入的新密碼不一致',
|
||||
'settings.lock.password.empty': '請填寫密碼',
|
||||
'settings.lock.password.saved': '密碼已儲存',
|
||||
'settings.lock.password.cleared': '鎖定密碼已清除',
|
||||
'settings.lock.password.forgot':
|
||||
'忘記鎖定密碼無法透過雲端找回,也沒有後門:只能刪除本機鎖定資料後重新設定。',
|
||||
'settings.lock.enabled': '啟用鎖定',
|
||||
'settings.lock.enabledDesc': '閒置或啟動時鎖定主視窗,解鎖需要上面的密碼',
|
||||
'settings.lock.needPassword': '請先設定鎖定密碼',
|
||||
'settings.lock.autoLock': '閒置自動鎖定',
|
||||
'settings.lock.autoLockDesc': '系統閒置超過該時間後自動鎖定(0 表示從不)',
|
||||
'settings.lock.autoLockOff': '關閉',
|
||||
'settings.lock.autoLockMinutes': '{n} 分鐘',
|
||||
'settings.lock.lockAtStartup': '啟動時鎖定',
|
||||
'settings.lock.lockAtStartupDesc': '每次啟動後先要求輸入密碼',
|
||||
'settings.lock.lockNow': '立即鎖定',
|
||||
'settings.lock.lockNowDesc': '馬上鎖定主視窗(工作階段保持運作)',
|
||||
'settings.lock.title': '已鎖定',
|
||||
'settings.lock.screenDesc': '輸入鎖定密碼以解鎖',
|
||||
'settings.lock.passwordPlaceholder': '密碼',
|
||||
'settings.lock.unlock': '解鎖',
|
||||
'settings.lock.unlocking': '解鎖中…',
|
||||
'settings.lock.screenForgot': '忘記密碼?鎖定密碼無法找回,只能刪除本機鎖定資料。',
|
||||
'settings.lock.error.invalidPassword': '密碼無效(不能為空或過短)',
|
||||
'settings.lock.error.wrongPassword': '密碼錯誤',
|
||||
'settings.lock.error.cooldown': '嘗試次數過多,請等待 {n} 秒後重試',
|
||||
'settings.lock.error.cooldownGeneric': '嘗試次數過多,請稍後再試',
|
||||
'settings.lock.error.saveFailed': '儲存失敗,請重試',
|
||||
'settings.resetTerminal': '恢復預設終端設定',
|
||||
'settings.resetTerminalTitle': '恢復預設終端設定?',
|
||||
'settings.resetTerminalDesc': '字體、游標、渲染與主題等全部終端設定將恢復為預設值。',
|
||||
|
||||
+52
-1
@@ -110,9 +110,60 @@ export const Ipc = {
|
||||
/** normalize a cwd reported by the shell (OSC 7 / OSC 9;9) */
|
||||
CWD_REPORT: 'session:cwdReport',
|
||||
/** open a local directory in the OS file manager (terminal toolbar) */
|
||||
CWD_OPEN: 'session:cwdOpen'
|
||||
CWD_OPEN: 'session:cwdOpen',
|
||||
|
||||
// ---- lock screen (main-window overlay; the main process owns the state) ----
|
||||
/** renderer pulls the current lock state without changing it */
|
||||
LOCK_STATE_GET: 'lock:stateGet',
|
||||
/** set or replace the password; an existing one must be verified first */
|
||||
LOCK_SET_PASSWORD: 'lock:setPassword',
|
||||
/** remove the password; the existing one must be verified first */
|
||||
LOCK_CLEAR_PASSWORD: 'lock:clearPassword',
|
||||
LOCK_UNLOCK: 'lock:unlock',
|
||||
LOCK_NOW: 'lock:now',
|
||||
/** main -> renderer broadcast: LockSettingsState */
|
||||
LOCK_STATE_CHANGED: 'lock:state'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The lock state the renderer sees. Deliberately free of salt, hash and
|
||||
* password: the stored verifier never leaves the main process.
|
||||
*/
|
||||
export interface LockSettingsState {
|
||||
/** a password is set, so the lock can engage at all */
|
||||
configured: boolean
|
||||
enabled: boolean
|
||||
autoLockMinutes: number
|
||||
lockAtStartup: boolean
|
||||
locked: boolean
|
||||
/** remaining lockout in ms; absent while no cooldown is running */
|
||||
cooldownMs?: number
|
||||
}
|
||||
|
||||
/** Passwords travel one way only: in. Neither field is ever echoed back. */
|
||||
export interface LockPasswordInput {
|
||||
/** required when a password is already set (replace / clear) */
|
||||
currentPassword?: string
|
||||
/** required when setting or replacing */
|
||||
newPassword?: string
|
||||
}
|
||||
|
||||
export type LockOperationError =
|
||||
/** the offered new password is unusable (empty, too short, too long) */
|
||||
| 'invalid-password'
|
||||
/** the offered current password does not match */
|
||||
| 'wrong-password'
|
||||
/** too many failed attempts: retry after state.cooldownMs */
|
||||
| 'cooldown'
|
||||
/** the verifier could not be written to disk */
|
||||
| 'save-failed'
|
||||
|
||||
export interface LockOperationResult {
|
||||
ok: boolean
|
||||
state: LockSettingsState
|
||||
error?: LockOperationError
|
||||
}
|
||||
|
||||
export interface PtyCreateOptions {
|
||||
cwd?: string
|
||||
/** executable; omit for platform default shell */
|
||||
|
||||
+41
-1
@@ -125,6 +125,41 @@ export function highlightModeOf(value: unknown): HighlightMode {
|
||||
return value === 'basic' || value === 'off' ? value : 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* The delays offered for the idle auto-lock, in minutes. A closed set rather
|
||||
* than a free number: `0` means "never", and the settings UI, the sanitizer and
|
||||
* the idle watcher all read this one list so they cannot disagree.
|
||||
*/
|
||||
export type LockAutoDelay = 0 | 1 | 5 | 15 | 30 | 60
|
||||
|
||||
export const LOCK_AUTO_DELAYS: LockAutoDelay[] = [0, 1, 5, 15, 30, 60]
|
||||
|
||||
/**
|
||||
* Read a stored auto-lock delay, tolerating a hand-edited settings.json: only a
|
||||
* whitelisted value survives, anything else falls back to 0 (never).
|
||||
*/
|
||||
export function lockAutoDelayOf(value: unknown): LockAutoDelay {
|
||||
return LOCK_AUTO_DELAYS.includes(value as LockAutoDelay) ? (value as LockAutoDelay) : 0
|
||||
}
|
||||
|
||||
export function isLockAutoDelay(value: unknown): value is LockAutoDelay {
|
||||
return LOCK_AUTO_DELAYS.includes(value as LockAutoDelay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-lock preferences. The password itself is never stored here — the
|
||||
* verifier lives in `<userData>/lock.json` (src/main/lockStore.ts), so settings
|
||||
* can be copied around, synced or logged without leaking it.
|
||||
*/
|
||||
export interface LockSettings {
|
||||
/** master switch: without it nothing ever locks, idle watcher included */
|
||||
enabled: boolean
|
||||
/** minutes of system idle before the screen locks; 0 = never */
|
||||
autoLockMinutes: LockAutoDelay
|
||||
/** start each run locked (asks for the password before the app is usable) */
|
||||
lockAtStartup: boolean
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
/** register the app to launch at OS login */
|
||||
launchAtLogin: boolean
|
||||
@@ -171,6 +206,8 @@ export interface AppSettings {
|
||||
/** named rule subsets for per-host highlighting (see terminal.highlightPerHost) */
|
||||
highlightProfiles: HighlightProfile[]
|
||||
system: SystemSettings
|
||||
/** screen lock; the password verifier lives outside settings (lock.json) */
|
||||
lock: LockSettings
|
||||
}
|
||||
|
||||
const RULE = (
|
||||
@@ -429,5 +466,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
customThemes: [],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES,
|
||||
highlightProfiles: [],
|
||||
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' }
|
||||
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' },
|
||||
// Off until the user sets a password and turns it on: an app that locks
|
||||
// itself out of the box would be a support ticket, not a feature.
|
||||
lock: { enabled: false, autoLockMinutes: 0, lockAtStartup: false }
|
||||
}
|
||||
Reference in new issue
Block a user