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 (
-
+
{ + if ((e.target as HTMLElement).closest('button')) return + void window.nexusterm.window.toggleMaximize() + }} + > ❯_ {t('app.name')} {t('app.tagline')} @@ -69,6 +79,8 @@ export default function App() { > + +
diff --git a/src/renderer/src/components/WindowControls.tsx b/src/renderer/src/components/WindowControls.tsx new file mode 100644 index 0000000..2605789 --- /dev/null +++ b/src/renderer/src/components/WindowControls.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Copy, Minus, Square, X } from 'lucide-react' + +/** + * Minimise / maximise / close, drawn by the app. + * + * The window is frameless, so the title bar that normally carries these is + * gone. Two consequences worth knowing: + * + * - The header has to declare itself a drag region (see `.header` in the + * stylesheet), or there is nothing left to drag the window by. + * - Maximise state is *read* 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, snapping to an edge. + */ +export default function WindowControls() { + const { t } = useTranslation() + const [maximized, setMaximized] = useState(false) + + useEffect(() => { + let cancelled = false + + void window.nexusterm.window.isMaximized().then((value) => { + if (!cancelled) setMaximized(value) + }) + + const unsubscribe = window.nexusterm.window.onMaximizedChange(setMaximized) + + return () => { + cancelled = true + unsubscribe() + } + }, []) + + return ( +
+ + + + + +
+ ) +} diff --git a/src/renderer/src/i18n/en-US.json b/src/renderer/src/i18n/en-US.json index 1d260e7..3b58f48 100644 --- a/src/renderer/src/i18n/en-US.json +++ b/src/renderer/src/i18n/en-US.json @@ -113,5 +113,11 @@ "reconnecting": "--- connection lost; reconnecting (attempt {{attempt}}, in {{seconds}}s) ---", "reconnected": "--- reconnected on attempt {{attempt}} ---\n--- note: this is a new shell; the previous session ended and its working directory is not restored ---", "disconnected": "--- disconnected: {{message}} ---" + }, + "window": { + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close window" } } diff --git a/src/renderer/src/i18n/zh-CN.json b/src/renderer/src/i18n/zh-CN.json index da5b21c..1ebb4a5 100644 --- a/src/renderer/src/i18n/zh-CN.json +++ b/src/renderer/src/i18n/zh-CN.json @@ -113,5 +113,11 @@ "reconnecting": "--- 连接断开,正在重连(第 {{attempt}} 次,{{seconds}} 秒后)---", "reconnected": "--- 已重连(第 {{attempt}} 次尝试成功)---\n--- 注意:这是一个新的 shell,上一个会话已结束,工作目录不会恢复 ---", "disconnected": "--- 连接已断开:{{message}} ---" + }, + "window": { + "minimize": "最小化", + "maximize": "最大化", + "restore": "还原", + "close": "关闭窗口" } } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 1ee930d..a42c3c7 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -72,9 +72,22 @@ body { display: flex; align-items: center; gap: 10px; - padding: 0 12px; + /* No left padding on the right edge: the window controls run to the corner, + as they do on every other window. */ + padding: 0 0 0 12px; background: var(--bg-raised); border-bottom: 1px solid var(--border); + /* The window is frameless, so this is the only thing left to drag it by. + Interactive children opt out below, or they would be undraggable and + unclickable. */ + -webkit-app-region: drag; +} + +.header button, +.header input, +.header select, +.header .banner { + -webkit-app-region: no-drag; } .header__mark { @@ -97,6 +110,38 @@ body { flex: 1; } +/* ---------- window controls ---------- */ + +.wincontrols { + display: flex; + align-self: stretch; + margin-left: 2px; + -webkit-app-region: no-drag; +} + +.wincontrols__button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + border: 0; + background: none; + color: var(--text-dim); + cursor: default; +} + +.wincontrols__button:hover { + background: rgba(255, 255, 255, 0.09); + color: var(--text); +} + +/* Red, as every window's close button is — the one control that should need + no reading to identify. */ +.wincontrols__button--close:hover { + background: #c42b1c; + color: #fff; +} + /* ---------- security banner ---------- */ .banner { diff --git a/src/shared/api.ts b/src/shared/api.ts index dc4b0f0..1ecd6e0 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -82,4 +82,20 @@ export interface NexusTermApi { remove(input: CredentialDeleteInput): Promise has(hostId: number, kind: CredentialKind): Promise } + + /** + * Window controls for a frameless window. + * + * The title bar that normally carries these is gone, so the app draws its + * own — which also means the drag region has to be declared in CSS, since + * there is no longer a title bar to drag. + */ + window: { + minimize(): Promise + toggleMaximize(): Promise + close(): Promise + isMaximized(): Promise + /** Returns a function that removes the listener. */ + onMaximizedChange(listener: (maximized: boolean) => void): () => void + } } diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index afcf58e..720f1ec 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -36,6 +36,14 @@ export const CH = { CREDENTIAL_SET: 'credential:set', CREDENTIAL_DELETE: 'credential:delete', CREDENTIAL_HAS: 'credential:has', + + // The window is frameless, so these replace what the title bar used to do. + WINDOW_MINIMIZE: 'window:minimize', + WINDOW_TOGGLE_MAXIMIZE: 'window:toggleMaximize', + WINDOW_CLOSE: 'window:close', + WINDOW_IS_MAXIMIZED: 'window:isMaximized', + /** Pushed from main when the window is maximised or restored. */ + WINDOW_STATE: 'window:state', } as const export type Channel = (typeof CH)[keyof typeof CH] diff --git a/src/shared/ipc-schemas.ts b/src/shared/ipc-schemas.ts index 3337eed..ae15c43 100644 --- a/src/shared/ipc-schemas.ts +++ b/src/shared/ipc-schemas.ts @@ -187,6 +187,10 @@ export const sessionCloseInputSchema = z.object({ sessionId: z.string().min(1).max(80), }) +/** Pushed from main when the window is maximised or restored. */ +export const windowStateSchema = z.object({ maximized: z.boolean() }) +export type WindowState = z.infer + /** A local shell either starts or it does not; there is no prompt to answer. */ export const localSessionResultSchema = z.discriminatedUnion('ok', [ z.object({ ok: z.literal(true), sessionId: z.string() }),