Use the desktop's own monospace font in the terminal
Read org.gnome.desktop.interface monospace-font-name at startup rather than hardcoding a family, so the terminal matches GNOME Terminal exactly and keeps matching after the system font changes. Pango reports points and CSS wants pixels, so 13 becomes 17.33px — rendering it as 13px made the terminal a third too small, which reads as the wrong font rather than the wrong size. The size is the trailing number, since families may contain digits. Families are always quoted, because an unquoted "Noto Sans Mono" is a three-family stack to CSS. Also fixes tests/dial.test.ts, which hardcoded the test server's fingerprint and so went red after every /tmp cleanup. It now asks ssh-keygen, which is a stronger assertion anyway: the expected value comes from the reference implementation rather than from this project's own fingerprint code.
This commit is contained in:
@@ -343,6 +343,30 @@ 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
|
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.
|
appears only with CJK or emoji output, which makes it easy to miss.
|
||||||
|
|
||||||
|
### Terminal font
|
||||||
|
|
||||||
|
The terminal uses **the desktop's own monospace font**, read from
|
||||||
|
`org.gnome.desktop.interface monospace-font-name` at startup. GNOME Terminal
|
||||||
|
defaults to `use-system-font`, so this is literally the same font rather than a
|
||||||
|
lookalike — and it stays the same font after someone changes their system
|
||||||
|
setting, which a hardcoded family would not.
|
||||||
|
|
||||||
|
Two details that are easy to get wrong:
|
||||||
|
|
||||||
|
- **Pango reports points, CSS wants pixels.** A `13` from GSettings is 13pt, and
|
||||||
|
rendering it as 13px makes the terminal a third too small — which reads as
|
||||||
|
"the font is wrong" rather than "the size is wrong". The conversion is 96/72.
|
||||||
|
- **The size is the *trailing* number.** Families legitimately contain digits
|
||||||
|
(`Fira Code 2` is a family), so splitting on the first number would be wrong.
|
||||||
|
|
||||||
|
The family is always quoted in the CSS value. An unquoted `Noto Sans Mono` is a
|
||||||
|
*stack* of three families as far as CSS is concerned, and the terminal would
|
||||||
|
silently fall back to something else.
|
||||||
|
|
||||||
|
A desktop that will not answer — not GNOME-based, or no `gsettings` — falls back
|
||||||
|
to `ui-monospace`, which asks the engine for the system monospace face. Same
|
||||||
|
request, resolved one layer down.
|
||||||
|
|
||||||
### Reconnecting
|
### Reconnecting
|
||||||
|
|
||||||
When a transport drops, the `Session` survives and only the backend beneath it
|
When a transport drops, the `Session` survives and only the backend beneath it
|
||||||
|
|||||||
@@ -241,6 +241,26 @@ preload contextBridge,窄白名单 —— 这个文件**就是**
|
|||||||
|
|
||||||
字节以 `Uint8Array` 跨界,绝不用字符串。TCP 经常把 UTF-8 字符切断在 chunk 之间,单独解码任一半都会损坏它;这个 bug 只在中文或 emoji 输出时出现,因此极易漏掉。
|
字节以 `Uint8Array` 跨界,绝不用字符串。TCP 经常把 UTF-8 字符切断在 chunk 之间,单独解码任一半都会损坏它;这个 bug 只在中文或 emoji 输出时出现,因此极易漏掉。
|
||||||
|
|
||||||
|
### 终端字体
|
||||||
|
|
||||||
|
终端使用**桌面自己的等宽字体**——启动时从 `org.gnome.desktop.interface
|
||||||
|
monospace-font-name` 读取。GNOME Terminal 默认是 `use-system-font`,所以这就是
|
||||||
|
**同一个字体**而不是仿制的;而且在用户改了系统设置之后它仍然跟着变,硬编码字
|
||||||
|
体名做不到这一点。
|
||||||
|
|
||||||
|
两个容易搞错的地方:
|
||||||
|
|
||||||
|
- **Pango 用点,CSS 用像素。** GSettings 给的 `13` 是 13pt,当成 13px 渲染会让
|
||||||
|
终端小三分之一——而这读起来像"字体不对",而不是"字号不对"。换算是 96/72。
|
||||||
|
- **尺寸是*最后*一个数字。** 字体名里合法地可以带数字(`Fira Code 2` 就是一个
|
||||||
|
字体名),按第一个数字切会切错。
|
||||||
|
|
||||||
|
字体名在 CSS 值里始终加引号。未加引号的 `Noto Sans Mono` 在 CSS 看来是**三个**
|
||||||
|
字体名组成的栈,终端会静默回退到别的字体。
|
||||||
|
|
||||||
|
不回答的桌面——非 GNOME、或没有 `gsettings`——回退到 `ui-monospace`,那是向引擎
|
||||||
|
要系统等宽字面。同一个请求,只是下沉一层解析。
|
||||||
|
|
||||||
### 重连
|
### 重连
|
||||||
|
|
||||||
传输断开时,`Session` 存活,只替换它底下的 backend——这正是那两者被拆成独立对象的理由。渲染进程保住了滚动历史和标签页,因为终端从未被拆掉。
|
传输断开时,`Session` 存活,只替换它底下的 backend——这正是那两者被拆成独立对象的理由。渲染进程保住了滚动历史和标签页,因为终端从未被拆掉。
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The font the desktop's own terminal would use.
|
||||||
|
*
|
||||||
|
* GNOME Terminal is configured with `use-system-font` by default, so what it
|
||||||
|
* renders is whatever `org.gnome.desktop.interface monospace-font-name` says.
|
||||||
|
* Reading that — rather than hardcoding a family — is what makes "the same font
|
||||||
|
* as GNOME Terminal" keep being true after someone changes their system font.
|
||||||
|
*/
|
||||||
|
export interface TerminalFont {
|
||||||
|
/** A CSS font-family value. */
|
||||||
|
family: string
|
||||||
|
/** In CSS pixels, converted from the point size the desktop reports. */
|
||||||
|
sizePx: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to use when the desktop will not say, or is not GNOME.
|
||||||
|
*
|
||||||
|
* `ui-monospace` is deliberately first: it asks the engine for the system
|
||||||
|
* monospace face, which is the same intent as the GSettings lookup, just
|
||||||
|
* resolved one layer down.
|
||||||
|
*/
|
||||||
|
export const FALLBACK_TERMINAL_FONT: TerminalFont = {
|
||||||
|
family:
|
||||||
|
'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace',
|
||||||
|
sizePx: 13,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SIZE_PX = 13
|
||||||
|
/** Pango reports points; CSS wants pixels. 1pt = 1/72in, 1px = 1/96in. */
|
||||||
|
const PT_TO_PX = 96 / 72
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Pango font description such as `'Ubuntu Mono 13'`.
|
||||||
|
*
|
||||||
|
* The size is the *trailing* number, not the first: families legitimately
|
||||||
|
* contain digits (`Noto Sans Mono 12` is a family called "Noto Sans Mono",
|
||||||
|
* while `Fira Code 12` vs `Fira Code 2 12` are different things). Splitting on
|
||||||
|
* the last whitespace-delimited number is the only rule that survives both.
|
||||||
|
*/
|
||||||
|
export function parseMonospaceFont(raw: string): TerminalFont | null {
|
||||||
|
// Either quote style, since the description's quoting is not part of what it
|
||||||
|
// means and the caller should not have to care which one it was handed.
|
||||||
|
const unquoted = raw.trim().replace(/^['"](.*)['"]$/, '$1').trim()
|
||||||
|
if (!unquoted) return null
|
||||||
|
|
||||||
|
const match = /^(.*\S)\s+(\d+(?:\.\d+)?)$/.exec(unquoted)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
// A bare family name with no size. Usable, sized by our default.
|
||||||
|
return { family: quoteFamily(unquoted), sizePx: DEFAULT_SIZE_PX }
|
||||||
|
}
|
||||||
|
|
||||||
|
const family = match[1].trim()
|
||||||
|
const points = Number(match[2])
|
||||||
|
if (!family || !Number.isFinite(points) || points <= 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
family: quoteFamily(family),
|
||||||
|
sizePx: Math.round(points * PT_TO_PX * 100) / 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always quote, on both paths.
|
||||||
|
*
|
||||||
|
* A CSS `font-family` value is a comma-separated stack, so an unquoted
|
||||||
|
* `Noto Sans Mono` is read as three families — "Noto", then "Sans", then
|
||||||
|
* "Mono" — and the terminal silently falls back to something else entirely.
|
||||||
|
* Quoting a single-word family costs nothing.
|
||||||
|
*/
|
||||||
|
function quoteFamily(family: string): string {
|
||||||
|
return `"${family.replace(/"/g, '')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the desktop for its monospace font.
|
||||||
|
*
|
||||||
|
* Runs `gsettings` once at startup and falls back silently. A desktop that is
|
||||||
|
* not GNOME-based simply will not answer, and that is not an error worth
|
||||||
|
* surfacing — the fallback stack is the same request made a different way.
|
||||||
|
*/
|
||||||
|
export function readTerminalFont(): TerminalFont {
|
||||||
|
try {
|
||||||
|
const raw = execFileSync(
|
||||||
|
'gsettings',
|
||||||
|
['get', 'org.gnome.desktop.interface', 'monospace-font-name'],
|
||||||
|
{ encoding: 'utf8', timeout: 3_000, stdio: ['ignore', 'pipe', 'ignore'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
return parseMonospaceFont(raw) ?? FALLBACK_TERMINAL_FONT
|
||||||
|
} catch {
|
||||||
|
return FALLBACK_TERMINAL_FONT
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@shared/ipc-schemas'
|
} from '@shared/ipc-schemas'
|
||||||
import type { AppContext } from '../context'
|
import type { AppContext } from '../context'
|
||||||
import { inspectSafeStorage } from '../security/safeStorageStatus'
|
import { inspectSafeStorage } from '../security/safeStorageStatus'
|
||||||
|
import { readTerminalFont } from '../desktop'
|
||||||
import * as connections from '../db/repositories/connections'
|
import * as connections from '../db/repositories/connections'
|
||||||
import {
|
import {
|
||||||
deleteCredential,
|
deleteCredential,
|
||||||
@@ -57,6 +58,7 @@ export function registerIpcHandlers(context: AppContext): void {
|
|||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
safeStorage: inspectSafeStorage(),
|
safeStorage: inspectSafeStorage(),
|
||||||
vault: context.vault.status,
|
vault: context.vault.status,
|
||||||
|
terminalFont: readTerminalFont(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import TerminalHost from './components/Terminal/TerminalHost'
|
|||||||
import HostKeyDialog from './components/Session/HostKeyDialog'
|
import HostKeyDialog from './components/Session/HostKeyDialog'
|
||||||
import PasswordDialog from './components/Session/PasswordDialog'
|
import PasswordDialog from './components/Session/PasswordDialog'
|
||||||
import { useSessionStore } from './stores/sessionStore'
|
import { useSessionStore } from './stores/sessionStore'
|
||||||
|
import { useSettingsStore } from './stores/settingsStore'
|
||||||
import { SUPPORTED_LOCALES, type Locale } from './i18n'
|
import { SUPPORTED_LOCALES, type Locale } from './i18n'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -18,6 +19,7 @@ export default function App() {
|
|||||||
|
|
||||||
const sessionError = useSessionStore((state) => state.error)
|
const sessionError = useSessionStore((state) => state.error)
|
||||||
const clearSessionError = useSessionStore((state) => state.clearError)
|
const clearSessionError = useSessionStore((state) => state.clearError)
|
||||||
|
const setTerminalFont = useSettingsStore((state) => state.setTerminalFont)
|
||||||
|
|
||||||
// No session is opened at startup. The fixed local entry in the sidebar is
|
// No session is opened at startup. The fixed local entry in the sidebar is
|
||||||
// always there to double-click; opening a shell nobody asked for would just
|
// always there to double-click; opening a shell nobody asked for would just
|
||||||
@@ -28,7 +30,10 @@ export default function App() {
|
|||||||
window.nexusterm.app
|
window.nexusterm.app
|
||||||
.info()
|
.info()
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
if (!cancelled) setInfo(value)
|
if (cancelled) return
|
||||||
|
setInfo(value)
|
||||||
|
// The terminal font comes from the desktop, not from this app.
|
||||||
|
setTerminalFont(value.terminalFont)
|
||||||
})
|
})
|
||||||
.catch((cause: unknown) => {
|
.catch((cause: unknown) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
|||||||
import { TerminalHandle } from '../../lib/terminals'
|
import { TerminalHandle } from '../../lib/terminals'
|
||||||
import { forgetSessionPort, onSessionPort } from '../../lib/sessionBridge'
|
import { forgetSessionPort, onSessionPort } from '../../lib/sessionBridge'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -23,11 +24,18 @@ export default function TerminalView({ sessionId, active }: TerminalViewProps) {
|
|||||||
const markClosed = useSessionStore((state) => state.markClosed)
|
const markClosed = useSessionStore((state) => state.markClosed)
|
||||||
const markStatus = useSessionStore((state) => state.markStatus)
|
const markStatus = useSessionStore((state) => state.markStatus)
|
||||||
|
|
||||||
|
const font = useSettingsStore((state) => state.terminalFont)
|
||||||
|
// Read at construction, but deliberately not a dependency of the effect that
|
||||||
|
// constructs: rebuilding the handle to change a font would throw away the
|
||||||
|
// scrollback. The effect below applies changes in place instead.
|
||||||
|
const fontRef = useRef(font)
|
||||||
|
fontRef.current = font
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const handle = new TerminalHandle(container)
|
const handle = new TerminalHandle(container, fontRef.current)
|
||||||
handleRef.current = handle
|
handleRef.current = handle
|
||||||
handle.onExit(() => markClosed(sessionId))
|
handle.onExit(() => markClosed(sessionId))
|
||||||
handle.onStatus((status) => markStatus(sessionId, status))
|
handle.onStatus((status) => markStatus(sessionId, status))
|
||||||
@@ -60,5 +68,11 @@ export default function TerminalView({ sessionId, active }: TerminalViewProps) {
|
|||||||
if (active) handle.focus()
|
if (active) handle.focus()
|
||||||
}, [active])
|
}, [active])
|
||||||
|
|
||||||
|
// Covers a terminal built before the main process reported the desktop's
|
||||||
|
// font, which is a matter of milliseconds but not one worth leaving to luck.
|
||||||
|
useEffect(() => {
|
||||||
|
if (font) handleRef.current?.setFont(font)
|
||||||
|
}, [font])
|
||||||
|
|
||||||
return <div className="terminal-pane" ref={containerRef} />
|
return <div className="terminal-pane" ref={containerRef} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TerminalFont } from '@shared/ipc-schemas'
|
||||||
import i18n from '../i18n'
|
import i18n from '../i18n'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
@@ -10,6 +11,14 @@ const SCROLLBACK_LINES = 10_000
|
|||||||
/** Acks are batched: one message per chunk would flood the port. */
|
/** Acks are batched: one message per chunk would flood the port. */
|
||||||
const ACK_INTERVAL_MS = 4
|
const ACK_INTERVAL_MS = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used only until the main process reports the desktop's font — normally a few
|
||||||
|
* milliseconds after startup, and in practice always before a session exists.
|
||||||
|
*/
|
||||||
|
const FALLBACK_FONT_FAMILY =
|
||||||
|
'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace'
|
||||||
|
const FALLBACK_FONT_SIZE = 13
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One terminal, its renderer addons, and its connection to the main process.
|
* One terminal, its renderer addons, and its connection to the main process.
|
||||||
*
|
*
|
||||||
@@ -30,13 +39,15 @@ export class TerminalHandle {
|
|||||||
private onExitCallback: ((code: number | null) => void) | null = null
|
private onExitCallback: ((code: number | null) => void) | null = null
|
||||||
private onStatusCallback: ((status: 'connected' | 'reconnecting') => void) | null = null
|
private onStatusCallback: ((status: 'connected' | 'reconnecting') => void) | null = null
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
constructor(container: HTMLElement, font: TerminalFont | null = null) {
|
||||||
this.container = container
|
this.container = container
|
||||||
|
|
||||||
this.terminal = new Terminal({
|
this.terminal = new Terminal({
|
||||||
fontFamily:
|
// The desktop's own terminal font, read at startup. Falls back to asking
|
||||||
'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace',
|
// the engine for the system monospace face, which is the same request
|
||||||
fontSize: 13,
|
// made one layer down.
|
||||||
|
fontFamily: font?.family ?? FALLBACK_FONT_FAMILY,
|
||||||
|
fontSize: font?.sizePx ?? FALLBACK_FONT_SIZE,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
// Without this addon CJK and emoji are measured as single cells and the
|
// Without this addon CJK and emoji are measured as single cells and the
|
||||||
@@ -120,6 +131,28 @@ export class TerminalHandle {
|
|||||||
this.terminal.focus()
|
this.terminal.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt a different font.
|
||||||
|
*
|
||||||
|
* A terminal created before the desktop's font was known would otherwise keep
|
||||||
|
* the fallback for its whole life, so this closes that race rather than
|
||||||
|
* leaving it to timing. xterm re-measures on an option change, but the
|
||||||
|
* resulting size has to be sent to the remote as well.
|
||||||
|
*/
|
||||||
|
setFont(font: TerminalFont): void {
|
||||||
|
if (this.disposed) return
|
||||||
|
if (
|
||||||
|
this.terminal.options.fontFamily === font.family &&
|
||||||
|
this.terminal.options.fontSize === font.sizePx
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.terminal.options.fontFamily = font.family
|
||||||
|
this.terminal.options.fontSize = font.sizePx
|
||||||
|
this.fitAndReport()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recompute cols/rows from the element's size and tell the remote.
|
* Recompute cols/rows from the element's size and tell the remote.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { TerminalFont } from '@shared/ipc-schemas'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appearance settings that come from outside the app.
|
||||||
|
*
|
||||||
|
* The terminal font is not a preference this app owns — it is the desktop's,
|
||||||
|
* read at startup. Keeping it in a store rather than threading it through props
|
||||||
|
* means a terminal created before the value arrives can still be corrected
|
||||||
|
* afterwards.
|
||||||
|
*/
|
||||||
|
interface SettingsState {
|
||||||
|
/** Null until the main process has answered. */
|
||||||
|
terminalFont: TerminalFont | null
|
||||||
|
setTerminalFont: (font: TerminalFont) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||||
|
terminalFont: null,
|
||||||
|
setTerminalFont: (font) => set({ terminalFont: font }),
|
||||||
|
}))
|
||||||
@@ -49,6 +49,13 @@ export const vaultStatusSchema = z.discriminatedUnion('state', [
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/** The desktop's own monospace font, so the terminal matches it. */
|
||||||
|
export const terminalFontSchema = z.object({
|
||||||
|
/** A CSS font-family value, already quoted if the family needs it. */
|
||||||
|
family: z.string().min(1),
|
||||||
|
sizePx: z.number().positive(),
|
||||||
|
})
|
||||||
|
|
||||||
export const appInfoSchema = z.object({
|
export const appInfoSchema = z.object({
|
||||||
appVersion: z.string(),
|
appVersion: z.string(),
|
||||||
electron: z.string(),
|
electron: z.string(),
|
||||||
@@ -57,6 +64,7 @@ export const appInfoSchema = z.object({
|
|||||||
platform: z.string(),
|
platform: z.string(),
|
||||||
safeStorage: safeStorageStatusSchema,
|
safeStorage: safeStorageStatusSchema,
|
||||||
vault: vaultStatusSchema,
|
vault: vaultStatusSchema,
|
||||||
|
terminalFont: terminalFontSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
// ------------------------------------------------------------------ the tree
|
// ------------------------------------------------------------------ the tree
|
||||||
@@ -219,6 +227,7 @@ export type TreeNode = FolderNode | HostNode
|
|||||||
|
|
||||||
export type SafeStorageStatus = z.infer<typeof safeStorageStatusSchema>
|
export type SafeStorageStatus = z.infer<typeof safeStorageStatusSchema>
|
||||||
export type VaultStatus = z.infer<typeof vaultStatusSchema>
|
export type VaultStatus = z.infer<typeof vaultStatusSchema>
|
||||||
|
export type TerminalFont = z.infer<typeof terminalFontSchema>
|
||||||
export type AppInfo = z.infer<typeof appInfoSchema>
|
export type AppInfo = z.infer<typeof appInfoSchema>
|
||||||
export type HostInput = z.infer<typeof hostInputSchema>
|
export type HostInput = z.infer<typeof hostInputSchema>
|
||||||
export type FolderCreateInput = z.infer<typeof folderCreateInputSchema>
|
export type FolderCreateInput = z.infer<typeof folderCreateInputSchema>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { FALLBACK_TERMINAL_FONT, parseMonospaceFont } from '../src/main/desktop'
|
||||||
|
|
||||||
|
describe('parseMonospaceFont', () => {
|
||||||
|
it('splits a family from its size', () => {
|
||||||
|
expect(parseMonospaceFont("'Ubuntu Mono 13'")).toEqual({
|
||||||
|
family: '"Ubuntu Mono"',
|
||||||
|
sizePx: 17.33,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts points to pixels', () => {
|
||||||
|
// Pango reports points and CSS wants pixels: 1pt = 1/72in, 1px = 1/96in.
|
||||||
|
// Getting this wrong makes the terminal a third too small, which reads as
|
||||||
|
// "the font is wrong" rather than "the size is wrong".
|
||||||
|
expect(parseMonospaceFont("'Monospace 12'")?.sizePx).toBe(16)
|
||||||
|
expect(parseMonospaceFont("'Monospace 9'")?.sizePx).toBe(12)
|
||||||
|
expect(parseMonospaceFont("'Monospace 10.5'")?.sizePx).toBe(14)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps multi-word families intact', () => {
|
||||||
|
// The failure this guards against is the family being read as a font stack:
|
||||||
|
// "Noto Sans Mono" is one family, not three.
|
||||||
|
expect(parseMonospaceFont("'Noto Sans Mono 12'")?.family).toBe('"Noto Sans Mono"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats the trailing number as the size, not the first one', () => {
|
||||||
|
// Families legitimately contain digits.
|
||||||
|
expect(parseMonospaceFont("'Fira Code 2 12'")).toEqual({
|
||||||
|
family: '"Fira Code 2"',
|
||||||
|
sizePx: 16,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts an unquoted description', () => {
|
||||||
|
expect(parseMonospaceFont('Ubuntu Mono 13')).toEqual({
|
||||||
|
family: '"Ubuntu Mono"',
|
||||||
|
sizePx: 17.33,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a bare family name, sizing it with the default', () => {
|
||||||
|
// Some desktops report only the family. Dropping it would be worse than
|
||||||
|
// using a sensible size.
|
||||||
|
expect(parseMonospaceFont("'Ubuntu Mono'")).toEqual({
|
||||||
|
family: '"Ubuntu Mono"',
|
||||||
|
sizePx: 13,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips quotes that would otherwise end up inside the CSS value', () => {
|
||||||
|
expect(parseMonospaceFont(`"Ubuntu Mono 13"`)?.family).toBe('"Ubuntu Mono"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects nonsense rather than producing a broken font string', () => {
|
||||||
|
for (const bad of ['', ' ', "''", '0', 'DejaVu 0', 'DejaVu -3']) {
|
||||||
|
const parsed = parseMonospaceFont(bad)
|
||||||
|
// Either null, or a usable family — never a zero or negative size, which
|
||||||
|
// xterm would take and render as nothing.
|
||||||
|
if (parsed) expect(parsed.sizePx).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
expect(parseMonospaceFont('')).toBeNull()
|
||||||
|
expect(parseMonospaceFont(' ')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never returns an empty family', () => {
|
||||||
|
for (const input of ["'Ubuntu Mono 13'", 'Ubuntu Mono 13', "'X'", 'X 12']) {
|
||||||
|
expect(parseMonospaceFont(input)?.family.length ?? 0).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FALLBACK_TERMINAL_FONT', () => {
|
||||||
|
it('asks the engine for the system monospace face', () => {
|
||||||
|
// The same intent as the GSettings lookup, resolved one layer down — so a
|
||||||
|
// desktop that will not answer still gets a sensible font.
|
||||||
|
expect(FALLBACK_TERMINAL_FONT.family).toContain('ui-monospace')
|
||||||
|
expect(FALLBACK_TERMINAL_FONT.family).toContain('monospace')
|
||||||
|
expect(FALLBACK_TERMINAL_FONT.sizePx).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
+57
-10
@@ -1,3 +1,4 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { userInfo } from 'node:os'
|
import { userInfo } from 'node:os'
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
@@ -28,9 +29,55 @@ import { createTempDb, type TempDb } from './helpers'
|
|||||||
*/
|
*/
|
||||||
const TEST_PORT = 2222
|
const TEST_PORT = 2222
|
||||||
const TEST_USER_KEY = '/tmp/testsshd/user_key'
|
const TEST_USER_KEY = '/tmp/testsshd/user_key'
|
||||||
const EXPECTED_FINGERPRINT = 'SHA256:ASmbFHszdZLp5YkSiJnqmGfAhpZKOZwQJnk3rLUo4Xo'
|
const TEST_HOST_KEY = '/tmp/testsshd/host_key.pub'
|
||||||
|
|
||||||
const prerequisitesPresent = existsSync(TEST_USER_KEY)
|
// ssh-keygen is what tells us the expected value, so it is part of the
|
||||||
|
// prerequisite too.
|
||||||
|
const prerequisitesPresent =
|
||||||
|
existsSync(TEST_USER_KEY) &&
|
||||||
|
existsSync(TEST_HOST_KEY) &&
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
execFileSync('ssh-keygen', ['-lf', TEST_HOST_KEY], { stdio: 'ignore' })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask `ssh-keygen` what the server's fingerprint is, rather than writing it
|
||||||
|
* down here.
|
||||||
|
*
|
||||||
|
* A literal would be tied to whichever key happened to exist when it was
|
||||||
|
* pasted in, and the test key is regenerated whenever `/tmp` is cleared — so
|
||||||
|
* the suite would go red after every reboot for a reason that has nothing to
|
||||||
|
* do with the code. A check that cries wolf is a check people learn to ignore.
|
||||||
|
*
|
||||||
|
* It is also a stronger assertion: the expected value comes from the reference
|
||||||
|
* implementation, not from this project's own fingerprint code.
|
||||||
|
*/
|
||||||
|
function expectedFingerprint(): string | null {
|
||||||
|
try {
|
||||||
|
const output = execFileSync('ssh-keygen', ['-lf', TEST_HOST_KEY], { encoding: 'utf8' })
|
||||||
|
return /SHA256:[A-Za-z0-9+/]+=*/.exec(output)?.[0] ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read fresh at each call rather than captured once.
|
||||||
|
*
|
||||||
|
* A module-level constant would be fine here, but a function makes it obvious
|
||||||
|
* at every call site that this value comes from outside — which is the whole
|
||||||
|
* point of not hardcoding it.
|
||||||
|
*/
|
||||||
|
function EXPECTED_FINGERPRINT(): string {
|
||||||
|
const value = expectedFingerprint()
|
||||||
|
if (!value) throw new Error('could not read the test sshd host key fingerprint')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
let temp: TempDb
|
let temp: TempDb
|
||||||
const openClients: Client[] = []
|
const openClients: Client[] = []
|
||||||
@@ -89,7 +136,7 @@ describe.runIf(prerequisitesPresent)('dial — trust on first use', () => {
|
|||||||
if (result.failure.reason !== 'unknown-host-key') return
|
if (result.failure.reason !== 'unknown-host-key') return
|
||||||
|
|
||||||
// The fingerprint shown must be the real one, or the user cannot check it.
|
// The fingerprint shown must be the real one, or the user cannot check it.
|
||||||
expect(result.failure.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
expect(result.failure.fingerprint).toBe(EXPECTED_FINGERPRINT())
|
||||||
expect(result.failure.keyType).toBe('ssh-ed25519')
|
expect(result.failure.keyType).toBe('ssh-ed25519')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,21 +149,21 @@ describe.runIf(prerequisitesPresent)('dial — trust on first use', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('connects when the presented fingerprint is trusted', async () => {
|
it('connects when the presented fingerprint is trusted', async () => {
|
||||||
const result = await attempt({}, EXPECTED_FINGERPRINT)
|
const result = await attempt({}, EXPECTED_FINGERPRINT())
|
||||||
|
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (result.ok) expect(result.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
if (result.ok) expect(result.fingerprint).toBe(EXPECTED_FINGERPRINT())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('remembers the key once trusted', async () => {
|
it('remembers the key once trusted', async () => {
|
||||||
await attempt({}, EXPECTED_FINGERPRINT)
|
await attempt({}, EXPECTED_FINGERPRINT())
|
||||||
|
|
||||||
const known = findKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519')
|
const known = findKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519')
|
||||||
expect(known?.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
expect(known?.fingerprint).toBe(EXPECTED_FINGERPRINT())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('connects without a prompt once the key is recorded', async () => {
|
it('connects without a prompt once the key is recorded', async () => {
|
||||||
await attempt({}, EXPECTED_FINGERPRINT)
|
await attempt({}, EXPECTED_FINGERPRINT())
|
||||||
|
|
||||||
const second = await attempt()
|
const second = await attempt()
|
||||||
|
|
||||||
@@ -143,7 +190,7 @@ describe.runIf(prerequisitesPresent)('dial — trust on first use', () => {
|
|||||||
expect(result.failure.reason).toBe('host-key-mismatch')
|
expect(result.failure.reason).toBe('host-key-mismatch')
|
||||||
if (result.failure.reason !== 'host-key-mismatch') return
|
if (result.failure.reason !== 'host-key-mismatch') return
|
||||||
expect(result.failure.expected).toBe('SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
|
expect(result.failure.expected).toBe('SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
|
||||||
expect(result.failure.actual).toBe(EXPECTED_FINGERPRINT)
|
expect(result.failure.actual).toBe(EXPECTED_FINGERPRINT())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports a readable failure for an unreadable key file', async () => {
|
it('reports a readable failure for an unreadable key file', async () => {
|
||||||
@@ -174,7 +221,7 @@ describe.runIf(prerequisitesPresent)('dial — trust on first use', () => {
|
|||||||
// never reaches authentication.
|
// never reaches authentication.
|
||||||
const result = await attempt(
|
const result = await attempt(
|
||||||
{ authType: 'password' },
|
{ authType: 'password' },
|
||||||
EXPECTED_FINGERPRINT,
|
EXPECTED_FINGERPRINT(),
|
||||||
'definitely-not-the-password',
|
'definitely-not-the-password',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user