276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import { app, BrowserWindow, dialog, 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 { applyMenuLockState } from './lockMenu'
|
|
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
|
|
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
|
import { configureAutoUpdater, registerUpdateIpc } from './updater'
|
|
import { applyWindowChrome } from './windowChrome'
|
|
import { onLanguageChange, t } 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. A restored startup lock engages before any publish,
|
|
// so the menu teardown is applied here from the flag itself.
|
|
const lock = initLockController()
|
|
applyMenuLockState(lock.isLocked())
|
|
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()
|
|
})
|
|
})
|
|
// A throw anywhere above (protocol, IPC, updater, lock controller, window,
|
|
// tray) used to leave the process running with the single-instance lock held
|
|
// and neither a window nor a tray to reach it — a zombie that refuses every
|
|
// later launch. Report and exit so a relaunch can retry from scratch.
|
|
.catch((err: unknown) => {
|
|
console.error('[main] startup failed:', err)
|
|
try {
|
|
dialog.showErrorBox(
|
|
'OpenTerminal',
|
|
t('main.startupFailed', { detail: err instanceof Error ? err.message : String(err) })
|
|
)
|
|
} catch {
|
|
// no display / dialog unavailable: the console line is all we have
|
|
}
|
|
app.exit(1)
|
|
})
|
|
}
|
|
|
|
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. Auto-repeats are skipped: the first keydown
|
|
// decides, and on an unconfigured app each repeat would otherwise re-read
|
|
// lock.json + settings.json from disk (~30 keydown/s while held).
|
|
// 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() && isPanicLockChord(input)) {
|
|
if (!input.isAutoRepeat && 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.
|
|
// Both loads are fire-and-forget, so the rejection is surfaced explicitly
|
|
// instead of dying as a floating promise.
|
|
const devUrl = devRendererUrl()
|
|
if (devUrl) {
|
|
void win.loadURL(devUrl).catch((err) => {
|
|
console.error('[main] loadURL failed:', err)
|
|
})
|
|
} else {
|
|
void win.loadFile(join(__dirname, '../renderer/index.html')).catch((err) => {
|
|
console.error('[main] loadFile failed:', err)
|
|
})
|
|
}
|
|
}
|
|
|
|
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()
|
|
}) |