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:
Bill committed 2026-09-24 22:16:43 +08:00
1 parent 471f8c3e73
commit 35583b2c15
47 files changed
+3058 -105

No files matched your search

+68 -2
View File
@@ -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 {
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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] : []
}
}
+401
View File
@@ -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())
}
+217
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {