diff --git a/README.md b/README.md index 827f526..03917cd 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,21 @@ Bytes cross as `Uint8Array`, never as a string. TCP splits UTF-8 characters across chunks routinely, and decoding either half alone corrupts them; the bug appears only with CJK or emoji output, which makes it easy to miss. +### The window frame + +There is no OS title bar and no application menu: the app draws its own +controls in the header and nothing else. Two consequences that are easy to miss: + +- **The header is the only drag region.** A window manager will not move a + window whose client area never claims `-webkit-app-region: drag`, and the + symptom is a window that simply cannot be moved — not an error. +- **Interactive children must opt out** with `no-drag`, or they become + undraggable and, on some platforms, unclickable. + +Maximise state is read from the window rather than tracked from the last button +press, because the window manager can maximise on its own — double-clicking the +drag region, a keyboard shortcut, or snapping to a screen edge. + ### Terminal font The terminal uses **the desktop's own monospace font**, read from diff --git a/README.zh-CN.md b/README.zh-CN.md index e3049cc..3205175 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -241,6 +241,19 @@ preload contextBridge,窄白名单 —— 这个文件**就是** 字节以 `Uint8Array` 跨界,绝不用字符串。TCP 经常把 UTF-8 字符切断在 chunk 之间,单独解码任一半都会损坏它;这个 bug 只在中文或 emoji 输出时出现,因此极易漏掉。 +### 窗口边框 + +没有系统标题栏,也没有应用菜单:应用在头部自绘窗口按钮,除此之外什么都没有。 +两个容易漏掉的连带后果: + +- **头部是唯一的拖动区域。** 如果客户区从不声明 `-webkit-app-region: drag`, + 窗口管理器就不会移动它——症状是窗口**完全拖不动**,而不是报错。 +- **可交互的子元素必须用 `no-drag` 显式退出**,否则它们既拖不动、在某些平台上 + 也点不动。 + +最大化状态是从窗口**读取**的,而不是根据上一次按钮点击推断的——因为窗口管理器 +可以自己最大化:双击拖动区、键盘快捷键、或贴边吸附。 + ### 终端字体 终端使用**桌面自己的等宽字体**——启动时从 `org.gnome.desktop.interface diff --git a/src/main/index.ts b/src/main/index.ts index 420deae..532c058 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ -import { app, BrowserWindow, session, shell } from 'electron' +import { app, BrowserWindow, Menu, session, shell } from 'electron' import { join } from 'node:path' +import { CH } from '@shared/ipc-contract' import { createAppContext, type AppContext } from './context' import { registerIpcHandlers } from './ipc/registry' @@ -42,6 +43,10 @@ function createWindow(): BrowserWindow { show: false, backgroundColor: '#14161a', title: 'NexusTerm', + // No OS title bar. The app draws its own controls in the header, so the + // header also has to declare itself a drag region in CSS — without that + // the window cannot be moved at all. + frame: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), // These three are the security boundary. The renderer runs untrusted @@ -57,6 +62,16 @@ function createWindow(): BrowserWindow { // Avoid the white flash before React has painted. win.once('ready-to-show', () => win.show()) + // The maximise button has to render differently when the window is already + // maximised, and the window can also be maximised by the window manager — + // double-clicking the drag region, a keyboard shortcut, snapping — so the + // state is pushed rather than inferred from the last button press. + const pushMaximized = (maximized: boolean): void => { + if (!win.isDestroyed()) win.webContents.send(CH.WINDOW_STATE, { maximized }) + } + win.on('maximize', () => pushMaximized(true)) + win.on('unmaximize', () => pushMaximized(false)) + // Anything that tries to open a new window or navigate away goes to the // system browser instead. A terminal emulator has no business hosting // arbitrary web content in-process. @@ -114,6 +129,10 @@ if (!gotTheLock) { }) void app.whenReady().then(() => { + // File/Edit/View/Window is a browser's menu, not a terminal's. It also + // brought its own accelerators that fought with the app's own shortcuts. + Menu.setApplicationMenu(null) + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { diff --git a/src/main/ipc/registry.ts b/src/main/ipc/registry.ts index af454af..df96cd6 100644 --- a/src/main/ipc/registry.ts +++ b/src/main/ipc/registry.ts @@ -1,4 +1,4 @@ -import { app, ipcMain } from 'electron' +import { app, BrowserWindow, ipcMain, type IpcMainInvokeEvent } from 'electron' import { CH } from '@shared/ipc-contract' import { appInfoSchema, @@ -137,4 +137,26 @@ export function registerIpcHandlers(context: AppContext): void { const { hostId, kind } = credentialRefInputSchema.parse(raw) return hasCredential(db, hostId, kind) }) + + // ---------------------------------------------------------------- window + // + // The window is frameless, so these stand in for the title bar. Each one + // resolves the window from the sender rather than holding a reference, so a + // second window (if one ever exists) cannot be closed by the first. + + const windowOf = (event: IpcMainInvokeEvent): BrowserWindow | null => + BrowserWindow.fromWebContents(event.sender) + + ipcMain.handle(CH.WINDOW_MINIMIZE, (event) => windowOf(event)?.minimize()) + + ipcMain.handle(CH.WINDOW_TOGGLE_MAXIMIZE, (event) => { + const win = windowOf(event) + if (!win) return + if (win.isMaximized()) win.unmaximize() + else win.maximize() + }) + + ipcMain.handle(CH.WINDOW_CLOSE, (event) => windowOf(event)?.close()) + + ipcMain.handle(CH.WINDOW_IS_MAXIMIZED, (event) => windowOf(event)?.isMaximized() ?? false) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 554c983..0b5139c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { CH } from '@shared/ipc-contract' import { SESSION_PORT_CHANNEL, type PortHandshake } from '@shared/session-protocol' +import type { WindowState } from '@shared/ipc-schemas' import type { NexusTermApi } from '@shared/api' /** @@ -44,6 +45,24 @@ const api: NexusTermApi = { remove: (input) => ipcRenderer.invoke(CH.CREDENTIAL_DELETE, input), has: (hostId, kind) => ipcRenderer.invoke(CH.CREDENTIAL_HAS, { hostId, kind }), }, + + window: { + minimize: () => ipcRenderer.invoke(CH.WINDOW_MINIMIZE), + toggleMaximize: () => ipcRenderer.invoke(CH.WINDOW_TOGGLE_MAXIMIZE), + close: () => ipcRenderer.invoke(CH.WINDOW_CLOSE), + isMaximized: () => ipcRenderer.invoke(CH.WINDOW_IS_MAXIMIZED), + + onMaximizedChange: (listener) => { + const handler = (_event: unknown, state: WindowState): void => listener(state.maximized) + ipcRenderer.on(CH.WINDOW_STATE, handler) + // Returned rather than left implicit: React's development-mode effects + // run twice, and without a way to unsubscribe each of those would add + // another listener for the lifetime of the window. + return () => { + ipcRenderer.removeListener(CH.WINDOW_STATE, handler) + } + }, + }, } /** diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 89f2627..2410d83 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,6 +8,7 @@ import TabBar from './components/Tabs/TabBar' import TerminalHost from './components/Terminal/TerminalHost' import HostKeyDialog from './components/Session/HostKeyDialog' import PasswordDialog from './components/Session/PasswordDialog' +import WindowControls from './components/WindowControls' import { useSessionStore } from './stores/sessionStore' import { useSettingsStore } from './stores/settingsStore' import { SUPPORTED_LOCALES, type Locale } from './i18n' @@ -54,7 +55,16 @@ export default function App() { return (