Files
OpenTerminal/src/main/index.ts
T
Bill 83dc3f15cc fix(lock): lock screen swallowed every keystroke; add Ctrl+L to lock
The renderer-side guard added in v1.0.17 called preventDefault on every
keydown while locked. A keydown's default action IS inserting the
character into the focused field, so the lock screen's password box
received nothing and a locked app could never be unlocked. Menu
accelerators are already stopped in main (before-input-event); the
renderer guard now just skips its own logic.

Also: Ctrl+L locks the screen from anywhere in the app, terminals
included. The chord is only taken when a lock actually engages, so an
unconfigured app keeps Ctrl+L for the shell's clear-screen.
2026-09-24 22:53:27 +08:00

278 lines
11 KiB
TypeScript

import { app, BrowserWindow, globalShortcut, net, nativeImage, protocol, shell } from 'electron'
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'
import { onLanguageChange } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
/** Re-create the main window (tray restore path after all windows are gone). */
function showOrCreate(): void {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
else {
const win = BrowserWindow.getAllWindows()[0]
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
}
/**
* Hand a link to the OS browser. Only the web schemes are allowed: a renderer
* that asked to open `file:`/`ms-*:`/anything else must not reach the shell.
*/
function openExternal(url: string): void {
try {
const { protocol } = new URL(url)
if (protocol === 'http:' || protocol === 'https:' || protocol === 'ftp:') {
void shell.openExternal(url)
}
} catch {
// unparsable target: nothing to open
}
}
// Dev runs get their own userData directory (settings, session snapshot,
// command history, logs) *and* their own single-instance lock — both are keyed
// on that path. Without this a dev instance fights the installed build: it
// takes the lock, so the other side is refused / must be killed, and it writes
// test data straight into the real profile. Must run before the lock below.
if (!app.isPackaged) {
app.setPath('userData', join(app.getPath('appData'), 'OpenTerminal-dev'))
}
// Custom background image (设置 → 主题): the renderer page cannot load file:
// subresources directly (Chromium blocks them from non-file origins), so it
// references them through this scheme instead. The handler serves exactly one
// file — the configured background image — nothing else.
protocol.registerSchemesAsPrivileged([
{ scheme: 'otimg', privileges: { secure: true, supportFetchAPI: false, corsEnabled: false } }
])
// Single instance: a second launch just surfaces the existing window (pulls
// it out of the tray if hidden there) instead of starting another process.
// The refused instance skips the whole startup path: `app.quit()` only asks
// the app to exit, so running the `whenReady` init behind it left a second
// process with IPC, a tray and an updater but no window.
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', () => showOrCreate())
app.whenReady().then(() => {
// Serve the configured background image (path lives in settings; anything
// else — including a path that is no longer configured — is refused, so the
// protocol cannot be used to read arbitrary files).
protocol.handle('otimg', (request) => {
const url = new URL(request.url)
const requested = decodeURIComponent(url.pathname.replace(/^\//, ''))
const allowed = loadSettings().terminal.backgroundImage
if (allowed === '' || requested !== allowed || !existsSync(requested)) {
return new Response('', { status: 403 })
}
return net.fetch(pathToFileURL(requested).toString())
})
registerIpc()
registerUpdateIpc()
// Before the window exists: a renderer-triggered check must not run against
// the updater's defaults (feed, proxy, autoDownload are set in here).
configureAutoUpdater()
// 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
// to be rebuilt whenever the interface language changes.
onLanguageChange(() => refreshTrayMenu())
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
}
/**
* 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.
const devIcon = join(__dirname, '../../build/icon.png')
const settings = loadSettings()
const theme = getThemeById(settings.terminal.themeId, settings.customThemes)
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 720,
minHeight: 480,
show: false,
backgroundColor: theme.colors.background,
autoHideMenuBar: true,
// Custom title bar: the renderer draws the strip, Windows draws the
// min/max/close overlay — colors follow the active terminal theme.
...(process.platform === 'win32'
? {
titleBarStyle: 'hidden' as const,
titleBarOverlay: {
color: theme.colors.background,
symbolColor: theme.colors.foreground,
height: 36
}
}
: {}),
...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
// Keep the default renderer sandbox (preload only touches the electron
// IPC bridge, so it does not need Node access).
sandbox: true
}
})
// Ctrl+L is the panic lock: it must work from anywhere in the app, terminals
// included, so it is captured here ahead of the page. It only takes the chord
// away when a lock actually engages — an unconfigured app keeps Ctrl+L for
// the shell's clear-screen.
// 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') return
const lock = getLockController()
if (
!lock.isLocked() &&
input.control &&
!input.shift &&
!input.alt &&
!input.meta &&
input.key.toLowerCase() === 'l'
) {
if (lock.lockNow().locked) event.preventDefault()
return
}
if (!lock.isLocked()) return
if (isLockBlockedShortcut(input)) event.preventDefault()
} catch {
/* an input guard must never take the window down with it */
}
})
win.on('ready-to-show', () => win.show())
// Dev window wears a "(dev)" tag so it is never confused with the installed
// build sitting next to it (they no longer share userData — see above).
if (!app.isPackaged) {
win.on('page-title-updated', (e) => {
e.preventDefault()
win.setTitle('OpenTerminal (dev)')
})
}
// Close button → tray / exit per the closeAction setting (ask by default).
win.on('close', (e) => {
void onMainWindowClose(win, e, showOrCreate)
})
// Surface renderer console errors in the dev terminal for diagnosis.
win.webContents.on('console-message', (event) => {
if (event.level === 'error') {
console.error(`[renderer] ${event.message}`)
}
})
// A renderer that crashed or was destroyed without running its own cleanup
// (normal close/reload kills its sessions via beforeunload first) leaves
// orphaned PTY/SSH transports behind — and session logs that keep appending.
const dropOwnedSessions = (): void => killPtysByOwner(win.webContents.id)
win.webContents.on('render-process-gone', dropOwnedSessions)
win.webContents.on('destroyed', dropOwnedSessions)
win.webContents.setWindowOpenHandler((details) => {
openExternal(details.url)
return { action: 'deny' }
})
// Dropping a local .html file on the window is a navigation like any other,
// and the new document would inherit this window's privileged preload. Only
// the app's own page may (re)load here.
win.webContents.on('will-navigate', (event, url) => {
if (!isTrustedRendererUrl(url)) event.preventDefault()
})
// 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 {
win.loadFile(join(__dirname, '../renderer/index.html'))
}
}
process.on('unhandledRejection', (reason) => {
console.error('[main] unhandledRejection:', reason)
})
process.on('uncaughtException', (err) => {
console.error('[main] uncaughtException:', err)
})
// Diagnostics for "app froze then recovered" reports: WER logs an
// AppHangTransient but keeps no stack. A stall of THIS (UI) thread is what
// Windows flags as a hang, so measure event-loop lag and say so loudly when
// it happens — that distinguishes a main-process block from a renderer one.
let lastTick = Date.now()
setInterval(() => {
const now = Date.now()
const lag = now - lastTick - 1000
if (lag > 2000) console.error(`[main] event loop stalled ~${lag}ms`)
lastTick = now
}, 1000).unref()
app.on('before-quit', () => {
// Let window 'close' events pass through so teardown completes.
markQuitting()
killAllPtys()
globalShortcut.unregisterAll()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})