Compare commits

...

5 Commits

Author SHA1 Message Date
陈大猫
109d0a7ab7 feat(terminal): add copy-host-address button to per-host statusbar (#951) (#952)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Adds a small clipboard-copy icon next to the host label / status dot in
the terminal pane's statusbar. Clicking copies the host's hostname
(IP or DNS name — what users called "machine IP" in #951) to the
clipboard and surfaces a toast.

The button only renders for non-local SSH/serial/telnet sessions —
local shells don't have an addressable hostname so showing it would
be confusing.

Placed in the pane statusbar (not the top tab) because the statusbar
is per-host: a workspace pane carries exactly one host, so the button
always identifies the right address. Top tabs in a workspace can share
multiple panes / hosts and would be ambiguous.

Visual treatment matches the surrounding stats buttons: 10px icon,
inline with the existing host label + status dot, opacity-60 →
opacity-100 on hover, `title` attribute for the tooltip to match the
pattern of the CPU/MEM/disk stats triggers right next to it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:51:50 +08:00
陈大猫
92ecd84edf Fix #939: per-host SSH keepalive override + cloud-friendly defaults (#947)
* fix(ssh): per-host keepalive override + cloud-friendly defaults (#939, #581)

Issues #939 (cloud / Aliyun sessions silently freezing after 15-20 min idle
because no SSH keepalive packets are sent) and #581 (older routers like
NOKIA / ALCATEL being killed by ssh2 after a few unanswered keepalives) are
in direct tension at the global-setting level: cloud users want keepalive
ON, embedded-device users want it OFF, and any single global default hurts
the other group.

Resolves the conflict by moving keepalive to a per-host setting (mirroring
the existing `legacyAlgorithms` per-host pattern), with cloud-friendly
global defaults:

Domain:
  - Host gains `keepaliveOverride?: boolean` + `keepaliveInterval?: number`
    + `keepaliveCountMax?: number`. When override is true, the host's
    values are used; otherwise the global TerminalSettings values apply.
    Per-field fallback so a host can override interval only or countMax only.
  - TerminalSettings gains `keepaliveCountMax: number` so the second knob
    (number of unanswered keepalives before declaring dead) is no longer
    hardcoded at 3 in the bridge.
  - DEFAULT_TERMINAL_SETTINGS: keepaliveInterval bumped from 0 to 30, and
    keepaliveCountMax = 10. Cloud LBs / NAT tables stay populated; brief
    network glitches don't trip the dead-connection check; an actually
    dead session is detected within ~5 minutes. Existing users with 0
    saved keep their value (no migration) — they were the #581 router
    cohort and their setup still works untouched.

Plumbing:
  - domain/host.ts adds resolveHostKeepalive(host, globalSettings) with
    five unit tests covering both directions of the override flag and
    per-field fallback.
  - components/terminal/runtime/createTerminalSessionStarters.ts uses the
    resolver when building startSSHSession options.
  - electron/bridges/sshBridge.cjs reads keepaliveCountMax from options
    (defaulting to 10) at both connection sites (direct + jump host) and
    still routes interval=0 through to a fully disabled keepalive
    (preserving #581's escape hatch).

UI:
  - Settings → Terminal → Connection grows a second input next to the
    existing interval: "Max unanswered keepalives".
  - Host details panel gains a Keepalive section with a "Override global
    keepalive" toggle that, when on, exposes per-host interval +
    countMax inputs and an inline hint when interval = 0 (explaining
    the implications). Same visual pattern as the existing Legacy
    Algorithms section.

Sync:
  - keepaliveCountMax added to SYNCABLE_TERMINAL_KEYS so the new global
    field rides existing sync infrastructure. Per-host fields ride the
    hosts array passthrough automatically (older clients receiving them
    ignore unknown fields, per the existing lenient sync contract).

i18n: en + zh-CN strings for the new settings row, the host section
header, and the override toggle / inputs / disabled hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): resolve keepalive per jump host, not just the final target

Addresses codex review on PR #947:
  https://github.com/binaricat/Netcatty/pull/947#discussion_r3217027xxx

The first cut only resolved keepalive for the final target host and
forwarded a single interval/countMax pair across the whole start-SSH
call. connectThroughChain in sshBridge.cjs then applied that one pair
to every hop, so a chain like:

   router (bastion, needs keepalive=0)  →  cloud target (needs 30s)

would either kill the router (with cloud-friendly defaults) or fail
to keep the target alive (with router-friendly 0). The per-host
override was effectively useless for bastion hosts.

Fix:
  - NetcattyJumpHost gains optional keepaliveInterval / keepaliveCountMax.
  - createTerminalSessionStarters runs resolveHostKeepalive() per
    jumpHost when building the chain, so each hop carries its own
    resolved pair.
  - sshBridge.cjs's chain connector reads jump.keepaliveInterval /
    jump.keepaliveCountMax for each hop, falling back to the call's
    target-level options for backward compatibility with older
    serializers that don't yet populate the per-hop fields.

The final target's keepalive path is unchanged — it still reads
options.keepaliveInterval / options.keepaliveCountMax that the
session starter resolves from the target host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): per-host keepalive for SFTP + port forwarding too

Follow-up to the maintainer review on PR #947 — terminal SSH was the
only path that honored per-host keepalive overrides. SFTP and port
forwarding share the same NetcattyJumpHost type but their builders
weren't resolving keepalive per-hop, and their bridges hardcoded the
old 10s/3 defaults. Net result: a router-as-bastion in a chain still
got killed when reached via the SFTP file panel or a port-forwarding
tunnel, even though the user had toggled per-host override.

Plumbing:
  - useSftpHostCredentials / buildSftpHostCredentials: accept optional
    terminalSettings; call resolveHostKeepalive() for the target and
    each jump entry; emit keepaliveInterval / keepaliveCountMax in the
    returned NetcattySSHOptions.
  - useSftpConnections + useSftpState + SftpStateOptions thread the
    setting down. SftpSidePanel passes the global terminalSettings prop
    it already has from TerminalLayer.
  - portForwardingService.startPortForward: accepts terminalSettings
    as an 8th argument, resolves per-host (target + each jump), and
    populates the bridge payload.
  - usePortForwardingState.startTunnel and usePortForwardingAutoStart
    forward the new parameter; App.tsx supplies terminalSettings (via
    a ref in the once-on-launch auto-start effect so changing global
    keepalive later doesn't re-fire it).

Bridges:
  - sftpBridge.cjs target connect: now also reads keepaliveCountMax
    from options (was hardcoded 3). 10s/3 stays as the bridge-level
    fallback to preserve the #669 protection when the renderer hasn't
    supplied a value.
  - sftpBridge.cjs jump hop: reads jump.keepaliveInterval /
    jump.keepaliveCountMax, then falls back to the target-call options
    (matches the symmetric SSH bridge change).
  - portForwardingBridge.cjs: reads keepaliveInterval /
    keepaliveCountMax from the IPC payload; same 10s/3 fallback.

Types:
  - NetcattyJumpHost already grew keepalive fields earlier; this
    commit also adds them to PortForwardOptions so the IPC contract
    is explicit.

End-to-end: a chain `[router-as-bastion, cloud-host]` with the
router host's keepaliveOverride=true / interval=0 now correctly
disables keepalive on the router hop for terminal SSH AND SFTP AND
port forwarding, while the cloud target still gets the resolved
30s/10 default for each path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): honor explicit keepalive=0 in SFTP + port forwarding bridges

Addresses codex review on PR #947:
  - https://github.com/binaricat/Netcatty/pull/947#discussion_r3217448xxx
  - https://github.com/binaricat/Netcatty/pull/947#discussion_r3217449xxx

The previous follow-up commit (5c8bc923) plumbed per-host keepalive
into SFTP / port forwarding but kept the existing bridge-level
"if interval > 0 use it, else 10s" fallback. That collapsed two
semantically distinct inputs:

  - "user explicitly resolved interval = 0" (host with keepaliveOverride
    + interval=0; the whole point of the override)
  - "no value supplied at all" (legacy serializer)

Both ended up as 10s in the bridge, so a router-as-bastion / direct
router connection through SFTP or a port-forward tunnel still got
ssh2-killed after countMax unanswered probes — exactly the case
per-host override was supposed to fix.

Fix: bridges now distinguish on `== null`:
  - positive value → honor it
  - explicit 0 → truly disabled (0 ms, 0 countMax — ssh2 skips its
    dead-connection check entirely on this connection)
  - undefined / null → fall back to 10s/3 (preserves #669 idle-NAT
    protection for older callers that pre-date per-host plumbing)

Applies to both SFTP target connect and SFTP jump hop builders, plus
the port forwarding target builder. Terminal SSH bridge is unchanged
since it already treated 0 as disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): plumb terminalSettings to all remaining keepalive call sites

Addresses codex review on PR #947:
  - PortForwardingNew + TrayPanel were not passing terminalSettings into
    startTunnel, so tunnels started from the main port-forwarding UI or
    from the tray menu silently used the FALLBACK 30/10 instead of the
    user's actual global keepalive settings. Hosts inheriting global
    policy could see different behavior depending on the entry point.
  - SftpView was not threading terminalSettings into useSftpState, so
    SFTP connections opened from the main tab UI also fell back to the
    same hardcoded default and ignored the user's settings.

Wiring:
  - PortForwardingProps gains `terminalSettings`; VaultView accepts it
    on the same prop and forwards from its own new prop; App.tsx
    supplies it from useSettingsState. The startTunnel call site uses
    it directly and includes it in the useCallback dep list so the
    handler updates when settings change.
  - SftpViewProps gains `terminalSettings`; SftpViewMount accepts and
    forwards it; the sftpOptions memo includes it in its dep list.
  - TrayPanelContent gains a `terminalSettings` prop; the TrayPanel
    wrapper (which already calls useSettingsState for uiLanguage)
    passes it down so the standalone tray window agrees with the main
    window's settings.

Also updates the explicit `startTunnel` signature in
UsePortForwardingStateResult so callers see the new 8th parameter
through the hook's return type, not just through the implementation.

Net result: every place that starts an SSH-derived connection
(terminal session, SFTP browse, port-forward tunnel) now consistently
sees the user's configured global keepalive policy and any per-host
overrides; the FALLBACK_KEEPALIVE constants in the service /
credentials builder are now only reached by genuinely-decoupled call
sites (tests, headless usage) rather than masking missing wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): include terminalSettings keepalive fields in memo comparators

Addresses codex review on PR #947 — all three components that grew a
`terminalSettings` prop (SftpView, SftpSidePanel, VaultView) are wrapped
in React.memo with manual equality comparators, and none of those
comparators were updated to include the new prop. React would skip the
re-render when global keepalive changed, so new SFTP / port-forwarding
connections from those subtrees would silently keep using the old
keepalive policy until some other tracked prop happened to flip.

Each comparator now compares the keepalive fields directly rather than
the whole terminalSettings object — only those two fields drive
connection resolution in this subtree, and ignoring the rest avoids
unnecessary re-renders for unrelated terminal-setting changes (fonts,
themes, etc.) that already have their own targeted comparator entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:22:12 +08:00
DeepFal
311f44525b Fix AI export menu theme colors (#944) 2026-05-11 15:00:49 +08:00
陈大猫
b4e185e1c6 fix(terminal): restore right-click paste in mouse-tracking TUIs (#941) (#946)
When a TUI app enables SGR mouse tracking (opencode, tmux with
`mouse on`, vim with `set mouse=a`, etc.), Terminal.tsx attaches a
capture-phase contextmenu listener that calls
stopImmediatePropagation. The original purpose is to bypass xterm.js's
own right-click handler — which calls textarea.select() and dismisses
TUI popup menus — but stopImmediatePropagation also kills the bubble
that React's onContextMenu delegation relies on, so
TerminalContextMenu's handleRightClick never fires.

Result: with `rightClickBehavior` set to "paste" (or "select-word"),
right-click silently does nothing inside any mouse-tracking TUI. Menu
mode still works because Radix opens via pointerdown (not affected by
the contextmenu capture block). Middle-click paste works because its
auxclick listener in createXTermRuntime is also unrelated to
contextmenu.

Fix: have the capture handler itself dispatch the user's chosen
right-click action when it intercepts the event. terminalContextActions
already exposes onPaste / onSelectWord; mirror them into a ref so the
once-bound capture handler can call the current implementation
without re-binding on every action identity change.

'context-menu' mode is intentionally not handled in the capture path —
Radix's pointerdown listener opens the menu independently.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:00:09 +08:00
陈大猫
92dd898eb4 Fix #931: let users pick a CJK font + per-font smart pairing (#940)
* feat(fonts): add CJK font pairing composition module

Introduces composeFontFamilyStack() which builds the xterm fontFamily
CSS string at runtime from:
  - the user's primary Latin font
  - an explicit CJK font (TerminalSettings.fallbackFont) if set
  - otherwise a per-Latin-font recommended CJK pairing
  - a hardcoded system CJK fallback stack
  - a Nerd Font icon fallback stack
  - the universal monospace generic

14 unit tests cover composition order, deduplication, OS defaults,
quoting, and recommendation override behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(fonts): expose raw Latin families and add CJK-coverage entries

- TERMINAL_FONTS[].family no longer bakes in the CJK fallback stack;
  composition is deferred to runtime via composeFontFamilyStack().
- Drops withCjkFallback helper from this module and its caller in
  lib/localFonts.ts.
- Adds 6 CJK-coverage primary fonts to the dropdown: Sarasa Mono SC/TC,
  Maple Mono CN, LXGW WenKai Mono, Microsoft YaHei UI, PingFang SC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(terminal): compose font-family stack with user-configurable CJK fallback

resolvedFontFamily now passes through composeFontFamilyStack(), which
prepends the user's TerminalSettings.fallbackFont (if set) ahead of the
per-Latin-font recommended CJK pairing and the system fallback stack.

The platform argument is derived from navigator.platform inside the
useMemo, so the same Latin font may pair with PingFang SC on macOS and
Microsoft YaHei UI on Windows out of the box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(settings): add CJK font picker to terminal settings

Adds a new "CJK font" select row right under the main font selector in
the Terminal settings tab. Bound to TerminalSettings.fallbackFont (an
already-existing-but-unused field), so this needs no schema or sync
payload change.

Default value "Auto" leaves fallbackFont empty, which lets the new
per-Latin-font pairing in cjkFonts.ts pick a CJK font automatically.
Selecting any explicit option (Sarasa Mono SC, PingFang SC, Microsoft
YaHei UI, etc.) takes precedence over the per-font pairing.

Includes en + zh-CN i18n strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(sync): cover fallbackFont round-trip + legacy payload tolerance

Four new test cases verify cloud-sync compatibility for the new CJK
font setting:

  - buildSyncPayload includes fallbackFont when set
  - buildSyncPayload omits fallbackFont when unset
  - applySyncPayload writes incoming fallbackFont to TERM_SETTINGS
  - applySyncPayload from a legacy client (no fallbackFont) does NOT
    wipe the local value — critical for old-to-new upgrades

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): add font availability detection (canvas + document.fonts API)

Three-layer detection used by isFontInstalled(family):
  1. Known @fontsource-bundled families (e.g. JetBrains Mono) always
     count as installed.
  2. document.fonts.check() — picks up @font-face and system-loaded fonts.
  3. Canvas width measurement against serif / sans-serif / monospace
     fallbacks; only counts if the target font produces a width that
     differs from ALL three generics for a probe string.

detectInstalledWithContext is a pure function taking an injected
measurement context, which keeps the canvas / DOM behind a seam and
lets the logic be unit-tested without a browser. 11 tests cover
quoted-family parsing, the three-generic-fallback rule, bundled
short-circuit, and document.fonts.check fast-path.

Results are cached per process; clearFontAvailabilityCache() invalidates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): filter dropdowns to fonts actually installed on this machine

Layer 3 of #931 added Sarasa Mono SC / Maple Mono CN / Microsoft YaHei UI
/ PingFang SC etc. to the terminal font dropdown, but users who don't
have these installed would still see them and pick them — resulting in
"I changed the font and nothing happened" confusion.

This commit filters both dropdowns through isFontInstalled():

  - TerminalFontSelect: drops any built-in or system-discovered font
    that detection can't render. If filtering would leave fewer than 4
    fonts (detection misfire safety net), shows the full list.

  - TerminalCjkFontSelect: keeps the "Auto" sentinel always, drops
    concrete CJK choices that aren't present on this machine.

Both selects always keep the currently-selected value visible — even
when the underlying font is missing — so users can read and clear
their setting without surprise.

Also expands `npm test` globs to pick up infrastructure/config/*.test.ts
and lib/*.test.ts, which previously matched no patterns and meant the
new cjkFonts and fontAvailability suites were silently excluded from
CI runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): never recommend proportional CJK fonts for terminal use

The previous PingFang SC / Microsoft YaHei UI / Hiragino Sans GB choices
were proportional sans-serif fonts whose CJK glyphs aren't designed to
fit a terminal's 2x cell grid — the rendered Chinese ended up visibly
wider than its allocated cells, breaking grid alignment (reported on
macOS with PingFang SC selected as the CJK font).

Changes:
  - TerminalCjkFontSelect: drops PingFang SC / Microsoft YaHei UI /
    Hiragino Sans GB from the dropdown. Legacy explicit selections
    still surface as a synthetic "not recommended" option so users can
    see and re-pick.
  - CJK_SYSTEM_FALLBACK_FONTS: monospace-only list. Sarasa Mono SC/TC,
    Maple Mono CN, LXGW WenKai Mono, Noto Sans Mono CJK SC, Source Han
    Mono SC, NSimSun, SimSun. Proportional fonts removed.
  - PER_FONT_CJK_PAIRING: every entry now points at a true monospace
    CJK font. Cascadia / Consolas / Menlo etc. all recommend Sarasa
    Mono SC, which the next commit bundles via @font-face.
  - getDefaultCjkFallback: Windows = SimSun (always installed,
    monospace); macOS = Sarasa Mono SC (will be bundled); Linux =
    Noto Sans Mono CJK SC. A regression test enforces that no
    per-OS default is a known proportional font.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): bundle Sarasa Mono SC as the universal CJK monospace

Previous commit removed proportional CJK fonts (PingFang SC, etc.)
from the picker and switched per-OS defaults to true monospace, but
macOS ships NO system-installed monospace CJK font — leaving macOS
users with a broken default unless they manually install Sarasa or
similar. This commit closes that gap by bundling Sarasa Mono SC as
an @font-face webfont, so the recommended pairings and macOS default
"just work" out of the box.

Details:
  - public/fonts/SarasaMonoSC-Regular.woff2 (~4.8 MB): subsetted from
    be5invis/Sarasa-Gothic v1.0.37 SarasaMonoSC-Regular.ttf (24 MB).
    Covers ASCII, Latin-1, common punctuation/symbols, CJK Unified
    Ideographs main block, Hiragana/Katakana, halfwidth/fullwidth,
    box-drawing — the everyday-Chinese coverage that matters for a
    terminal. Rare CJK Ext-A/B/historical chars fall through to the
    system fallback stack.
  - public/fonts/SarasaMono-LICENSE.txt: OFL-1.1 verbatim, required
    by the license.
  - index.css: @font-face declaration with font-display: swap so the
    user doesn't see a flash of nothing while the woff2 loads.
  - KNOWN_BUNDLED_FAMILIES: "Sarasa Mono SC" added so the dropdown
    availability filter doesn't hide it.

Installer impact: ~+4.8 MB (vs current ~100-200 MB Electron baseline).
The font replaces what would otherwise have been "Chinese chars look
broken in the terminal" for every macOS user without a manually
installed CJK monospace font.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use Local Font Access API as the authoritative install check

document.fonts.check() turned out to be unreliable as an installed-font
signal in Chromium — it returns true for any syntactically-valid family
name regardless of whether the font is actually installed, as a
deliberate fingerprinting-mitigation. The previous detector took it as
a positive signal and ended up keeping uninstalled fonts in the dropdown
(reported by a macOS user seeing dozens of fonts they don't have).

This commit pivots the detection chain:

  - lib/localFonts.ts: getAllSystemFontFamilies() exposes the unfiltered
    set of installed family names from queryLocalFonts(), reusing the
    same underlying call as getMonospaceFonts() via a shared cache.

  - lib/fontAvailability.ts: drops the document.fonts.check fast-path.
    Adds setSystemFamilies() / hasAuthoritativeData(). When the set has
    been populated, isFontInstalled answers from membership lookup
    directly — no canvas guessing. Canvas remains as a fallback for
    environments where the Local Font Access API is unavailable or
    permission is denied.

  - application/state/fontStore.ts: during initialize(), runs the
    monospace-only query and the full-system-families query together,
    then pipes the result into fontAvailability.

  - TerminalFontSelect: with authoritative data, drops the "if filtered
    list is suspiciously small, show all" safety net. Empty would now
    really mean empty (highly unlikely since Sarasa Mono SC is bundled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): drop PingFang SC / Microsoft YaHei UI from primary dropdown

Step 1 of this PR removed proportional CJK fonts from the CJK fallback
picker but left them in BASE_TERMINAL_FONTS, so PingFang SC and
Microsoft YaHei UI were still selectable as the *primary* terminal
font. Picking PingFang SC as primary produced visibly bloated Latin
character spacing (xterm.js samples cell width from the primary font;
the wide proportional 'M' inflates every cell), reported by a macOS
user in the same thread that opened #931.

Both entries are removed from BASE_TERMINAL_FONTS. A new
infrastructure/config/fonts.test.ts asserts that no known proportional
CJK font name (including PingFang TC/HK, Microsoft YaHei variants,
Hiragino Sans GB, Heiti SC/TC) is ever shipped in TERMINAL_FONTS as a
primary choice.

Migration for users already saved to one of the removed ids:
useSettingsState rewrites STORAGE_KEY_TERM_FONT_FAMILY to the default
(Menlo) on read when it sees a deprecated id, so the bad value also
stops getting carried into cloud-sync uploads. Per-host fontFamily
overrides are NOT migrated automatically — they still gracefully
fall through to the dropdown's first entry via the existing
getFontById fallback; users can re-pick from the host settings UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): drop Comic Sans MS — it's a proportional handwriting font

Same symptom as the PingFang SC / Microsoft YaHei UI removal: Comic
Sans MS was historically in the primary font dropdown labeled
"Casual, non-traditional terminal font", but Comic Sans is a
handwriting-style proportional sans-serif. Picking it as the terminal
primary inflates cell width and spaces every Latin character far
apart (reported in the same #931 thread).

- BASE_TERMINAL_FONTS: comic-sans-ms entry removed.
- DEPRECATED_PRIMARY_FONT_IDS: gains comic-sans-ms so existing
  selections silently migrate to Menlo on read.
- fonts.test.ts: the proportional-font ban list now also covers
  Latin proportional fonts (Comic Sans MS, Arial, Helvetica, Times
  New Roman, Georgia, Verdana, Trebuchet MS, Tahoma) so the test
  catches any future mislabeled body-text font from being added to
  the terminal dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): keep monospace ahead of CJK fallbacks in composed stack

Addresses codex P1 review comment on PR #940
(https://github.com/binaricat/Netcatty/pull/940#discussion_r3216017737).

The previous behavior of withCjkFallback() had monospace immediately
after the primary family, before any CJK fallback. composeFontFamilyStack
had moved monospace to the very end, which means: when the primary
font isn't installed on the user's machine (common for Layer 3 CJK
choices that aren't bundled and not present on a given OS, or for any
built-in id like cascadia-code on a Linux system without it), CSS
per-glyph fallback resolves Latin glyphs from a CJK font's full-width
Latin variants before ever reaching monospace generic. That breaks
xterm.js's fixed cell-grid alignment.

The composed stack now reads:
  <primary>, monospace, <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>

Per-glyph CSS fallback behavior:
  - Latin → primary if installed → monospace generic. Cell width
    stays consistent.
  - CJK → primary (no) → monospace (no Chinese glyphs) → walks into
    CJK fallbacks.
  - Nerd PUA → falls past all of the above into the Nerd Font stack.

Updates the position-invariant tests and adds a regression test that
explicitly asserts monospace appears before every CJK family in the
output stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): dedupe Local Font Access API calls under concurrent init

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216246xxx

fontStore.initialize() runs getMonospaceFonts() and
getAllSystemFontFamilies() in Promise.all; both internally called
queryAllSystemFontsOnce(), whose cache check (`if (cache) return`) was
only useful once the result had been written. Concurrent callers both
passed the empty-cache check and fired their own queryLocalFonts()
request — two real Local Font Access API invocations on cold start,
with the risk of one succeeding while the other was denied (leaving
the authoritative set unset).

Fix: cache the *in-flight promise itself*, so subsequent callers
await the same single invocation. The first await populates the
family-set cache as a side effect, and the resolved promise keeps
returning the same value to every subsequent caller.

Adds lib/localFonts.test.ts with three regression tests:
  - concurrent getMonospaceFonts + getAllSystemFontFamilies = 1 API call
  - sequential repeats also reuse the resolved promise
  - missing API returns null authoritative set (canvas fallback signal)

Exports __resetLocalFontsCacheForTesting() so each test gets a fresh
module-level state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): retry LFA on transient failure + notify on availability changes

Two follow-up fixes from codex P2 review on PR #940:

1) queryAllSystemFontsOnce() previously kept its in-flight promise even
   when queryLocalFonts threw. Subsequent callers reused the cached
   empty result for the rest of the session, so any transient failure
   at boot (permission state not ready, AbortError, etc.) permanently
   blinded the rest of the app to installed fonts. Catch now clears
   queryPromise so the next caller retries. Regression test added.

2) TerminalCjkFontSelect.visibleOptions and TerminalFontSelect
   .visibleFonts were memoized on [value] / [fonts, value] only, but
   the filter calls isFontInstalled() which reads module-level
   systemFamilies — a value that arrives asynchronously after the
   initial render. The memos never recomputed when authoritative
   availability data landed, so the dropdowns could continue showing
   stale "filtered" results until the user changed selection.

   fontAvailability now exposes subscribeFontAvailability() and
   getFontAvailabilityVersion() (monotonic counter bumped on
   setSystemFamilies / clearFontAvailabilityCache). Both selects
   subscribe via useSyncExternalStore and include the version in
   their memo deps; tests cover subscriber notification and version
   monotonicity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): migrate host/group deprecated font ids + localize CJK labels

Two follow-up fixes from codex review on PR #940:

P2 — Host/group level font migration
====================================
The earlier deprecated-id migration only rewrote
STORAGE_KEY_TERM_FONT_FAMILY, so hosts and group configs that had
explicitly opted into a now-removed font id (e.g. pingfang-sc,
microsoft-yahei, comic-sans-ms) kept `fontFamily` set with
`fontFamilyOverride=true`. After the dropdown entries were dropped
in 9f2bd282/c9b622d8, those records silently fell through to the
first font in the registry (Menlo) while the override flag still
read "true" — users saw a host claiming a custom font but rendering
the global default with no way to tell what happened.

Fix:
  - infrastructure/config/fonts.ts gains migrateDeprecatedFontOverride(),
    a structurally-shared helper that drops fontFamily and clears
    fontFamilyOverride when the id is deprecated.
  - sanitizeHost now runs it on every host load.
  - domain/groupConfig.ts grows sanitizeGroupConfig(); useVaultState
    applies it both on initial load and on cross-tab storage events.
  - Existing decrypt → sanitize → encrypt round-trip in useVaultState
    means the migrated values are persisted back to localStorage and
    propagate through cloud sync naturally.

Tests: two each in domain/host.test.ts and domain/groupConfig.test.ts
covering deprecated-id reset and untouched-valid-id preservation.

P3 — Localize CJK font option labels
====================================
TerminalCjkFontSelect previously hardcoded Chinese option labels
("Auto · 按主字体智能搭配", "Sarasa Mono SC (更纱黑体 简)", etc.) and
the synthetic "not recommended" warning. Non-Chinese locales saw a
mixed-language UI despite the rest of the setting going through i18n.

OPTIONS now references i18n keys; the component looks them up via
useI18n(). Both en and zh-CN locales gain matching keys, including
`...option.legacy` with `{font}` interpolation for the synthetic
"not recommended" item that surfaces saved-but-removed values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): also sanitize group configs on the write/import path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216314xxx

The previous commit (09c87820) added sanitizeGroupConfig() but only
plumbed it into the decrypt paths (initial load + storage event).
updateGroupConfigs() — which is also the write path used by
applySyncPayload / importVaultData when ingesting a legacy payload —
still set state from raw input. A sync from an older client carrying
{ fontFamily: "pingfang-sc", fontFamilyOverride: true } would land in
memory unsanitized AND be re-persisted with the bad override active
until the next reload re-ran the decrypt path.

Fix mirrors updateHosts → sanitizeHost: map every incoming entry
through sanitizeGroupConfig before both setGroupConfigs and the
encrypt-and-persist step. Same call site now feeds the cleaned data
to localStorage, so legacy values are scrubbed on first import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): migrate deprecated terminal font ids on every ingest path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216517xxx

The previous migration only ran in the initial useState() initializer
for terminalFontFamilyId, so deprecated ids (pingfang-sc /
microsoft-yahei / comic-sans-ms) could still re-enter state via:

  - rehydrateAllFromStorage() at line ~527 — runs on remote-import
    completion and re-reads STORAGE_KEY_TERM_FONT_FAMILY raw.
  - The notifySettingsChanged IPC handler at line ~663 — fires when a
    cloud sync or programmatic localStorage write announces a change.
  - The cross-window storage event handler at line ~873.

Any of these paths could pull a deprecated id back into state after
the initial migration ran, leaving the font selector with no matching
option and silently rendering the global default while continuing to
propagate the stale value through subsequent sync uploads.

Centralizes the migration in migrateIncomingTerminalFontId(raw):
  - returns null when raw is empty
  - if raw is deprecated, writes DEFAULT_FONT_FAMILY back to
    localStorage AND returns it
  - otherwise returns raw unchanged

All four ingest sites (initial init, rehydrate, IPC, storage event)
now route through this helper. The rewrite-on-deprecated semantics
also guarantee that the moment any path sees a bad value, the next
sync upload carries the cleaned default — not the deprecated id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use bundled Latin-only fallback instead of monospace generic

Resolves the tension between codex's two P1 reviews on PR #940:

  Round 1 (da1fe4cd): "monospace must come BEFORE CJK fallbacks" —
    otherwise Latin glyphs fall into a CJK font's full-width Latin
    when the primary font is missing.

  Round 2 (this commit): "monospace must come AFTER CJK fallbacks" —
    otherwise on macOS Chrome, the generic `monospace` pulls in
    PingFang via Chromium's CJK system fallback and silently masks
    the user's CJK picker.

Both are right; using a single `monospace` token can't satisfy both
roles because `monospace` is a generic family whose CJK-glyph
coverage is platform-dependent.

Fix mirrors Tabby's approach (their "monospace-fallback" SourceCodePro
sitting before any CJK in the chain): insert a known Latin-only
bundled font between the primary and CJK fallbacks. JetBrains Mono is
already shipped via @fontsource/jetbrains-mono and carries no CJK
glyphs, so it catches Latin without intercepting Chinese.

New stack order:
  <primary>, "JetBrains Mono", <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>, monospace

Per-glyph CSS fallback now behaves as intended on every platform:
  - Latin: primary (if installed) → JetBrains Mono. Cells stay aligned.
  - CJK: primary (no) → JetBrains Mono (no CJK glyphs) → user CJK pick.
  - Nerd PUA: all of the above → Nerd Font stack.

Replaces the two prior positional-invariant tests with one for each
codex review concern: JetBrains Mono precedes every CJK family
(Latin alignment), and user CJK precedes generic monospace (CJK
picker effectiveness).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use OR-of-fallbacks for canvas font detection

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216556xxx

detectInstalledWithContext required the target font to produce a
different rendered width from *all three* generic fallbacks (serif,
sans-serif, monospace) to be counted as installed. That's too strict:
on macOS the `monospace` generic resolves to Menlo itself, so
measure(`"Menlo", monospace`) === measure(`monospace`), and the
detector reported Menlo as missing even when it was clearly installed.
The same false-negative trap exists for any font that happens to
share metrics with one of the three generics on a given platform.

Switches to OR-of-fallbacks: a font counts as installed if its
rendered width differs from at least one generic baseline. A truly
uninstalled font still falls through to each generic in turn and
matches all three baselines, so this doesn't introduce false positives.

Regression tests added for both directions:
  - Menlo with metrics identical to `monospace` generic → installed.
  - "Definitely Not Installed" font → still reported missing.

The path only fires when the Local Font Access API is unavailable or
denied — when LFA succeeds, `setSystemFamilies` short-circuits ahead of
canvas — so this primarily improves the degraded-permission scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): quote-aware tokenizer for font-family lists

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216559xxx

composeFontFamilyStack and extractPrimaryFamily both tokenized their
input with a raw String.split(',') — which corrupts any CSS family
list whose quoted family name contains a comma (CSS allows that, e.g.
`"Foo, Inc. Mono"` is a single family). A naive split would shred
that into `"Foo` / `Inc. Mono"` and emit a malformed font-family back
out.

No current TERMINAL_FONTS entry hits this case, but lib/localFonts.ts
builds family strings from arbitrary system fonts via the Local Font
Access API — a user with a comma-bearing family name would have
silently broken filtering until now.

Adds splitFontFamilyList(css) in cjkFonts.ts: an exported quote-aware
tokenizer that splits on commas only when outside quoted segments
(handles both " and '). composeFontFamilyStack uses it instead of raw
split; extractPrimaryFamily in lib/fontAvailability.ts imports it for
symmetry so the two call sites can't drift.

Tests cover the tokenizer directly (simple list, quoted-with-comma,
single quotes, double commas) and end-to-end (a quoted primary with
an internal comma survives composition intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): translate Layer 3 CJK font descriptions to English

The 4 CJK-coverage entries added in earlier commits (Sarasa Mono SC,
Sarasa Mono TC, Maple Mono CN, LXGW WenKai Mono) had hardcoded Chinese
description strings, while every other TERMINAL_FONTS entry uses
English ('Adobe's professional programming font', 'Iosevka variant
mimicking Berkeley Mono style', etc.). The dropdown rendered a
mixed-language list — flagged by the maintainer.

Converted the 4 descriptions to English in the same style as the
existing entries. No i18n scaffolding added; the existing convention
is "English-only `description` field, not routed through t()", and
the rest of the registry stays consistent with that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:07:15 +08:00
49 changed files with 2182 additions and 367 deletions

View File

@@ -629,7 +629,7 @@ function App({ settings }: { settings: SettingsState }) {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}, rule.autoStart, terminalSettings);
return;
}
@@ -836,6 +836,7 @@ function App({ settings }: { settings: SettingsState }) {
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
});
// Sync tray menu data + handle tray actions
@@ -2035,6 +2036,7 @@ function App({ settings }: { settings: SettingsState }) {
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
@@ -2054,6 +2056,7 @@ function App({ settings }: { settings: SettingsState }) {
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
terminalSettings={terminalSettings}
/>
<TerminalLayerMount

View File

@@ -273,6 +273,17 @@ const en: Messages = {
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
'settings.terminal.font.family': 'Font',
'settings.terminal.font.family.desc': 'Terminal font family',
'settings.terminal.font.cjk': 'CJK font',
'settings.terminal.font.cjk.desc': 'Font used for Chinese / Japanese / Korean characters; "Auto" picks one based on the primary font',
'settings.terminal.font.cjk.option.auto': 'Auto · paired with the primary font',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
'settings.terminal.font.cjk.option.simSun': 'SimSun',
'settings.terminal.font.cjk.option.legacy': '{font} · not recommended (proportional font)',
'settings.terminal.font.size': 'Font size',
'settings.terminal.font.size.desc': 'Terminal text size',
'settings.terminal.font.weight': 'Font weight',
@@ -374,7 +385,9 @@ const en: Messages = {
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
'settings.terminal.connection.keepaliveCountMax.desc': 'Unanswered keepalives before the connection is declared dead. Higher values are more forgiving of brief network glitches and SSH servers that respond slowly.',
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
@@ -1119,6 +1132,12 @@ const en: Messages = {
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Override global keepalive',
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
'hostDetails.keepalive.interval': 'Interval (seconds)',
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
@@ -1263,6 +1282,10 @@ const en: Messages = {
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
'terminal.statusbar.copyHostname.label': 'Copy host address',
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',

View File

@@ -748,6 +748,12 @@ const zhCN: Messages = {
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.section.keepalive': '会话保活',
'hostDetails.keepalive.override': '为此主机单独配置',
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
'hostDetails.keepalive.interval': '间隔(秒)',
'hostDetails.keepalive.countMax': '最大无响应保活次数',
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
@@ -857,6 +863,10 @@ const zhCN: Messages = {
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
'terminal.statusbar.copyHostname.label': '复制主机地址',
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname}',
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
@@ -1402,6 +1412,17 @@ const zhCN: Messages = {
'settings.terminal.section.keywordHighlight': '关键字高亮',
'settings.terminal.font.family': '字体',
'settings.terminal.font.family.desc': '终端字体',
'settings.terminal.font.cjk': '中文 / CJK 字体',
'settings.terminal.font.cjk.desc': '用于渲染中 / 日 / 韩字符的字体;"Auto" 会按主字体智能搭配',
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字体智能搭配',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更纱黑体 简)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更纱黑体 繁)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等宽)',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鹜文楷等宽)',
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋体)',
'settings.terminal.font.cjk.option.legacy': '{font} · 不推荐(非等宽字体)',
'settings.terminal.font.size': '字体大小',
'settings.terminal.font.size.desc': '终端文字大小',
'settings.terminal.font.weight': '字重',
@@ -1496,7 +1517,9 @@ const zhCN: Messages = {
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
'settings.terminal.connection.keepaliveCountMax.desc': '判定连接死亡前允许的无响应保活次数。值越大对短暂网络抖动和响应慢的 SSH 服务越宽容。',
'settings.terminal.connection.x11Display': 'X11 显示地址',
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY',

View File

@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
import { getMonospaceFonts } from '../../lib/localFonts';
import { getAllSystemFontFamilies, getMonospaceFonts } from '../../lib/localFonts';
import { setSystemFamilies } from '../../lib/fontAvailability';
/**
* Global font store - singleton pattern using useSyncExternalStore
@@ -60,7 +61,14 @@ class FontStore {
this.setState({ isLoading: true, error: null });
try {
const localFonts = await getMonospaceFonts();
// Populate the authoritative installed-family set used by
// fontAvailability.isFontInstalled. Runs in parallel with the
// monospace-only query (both share an underlying cache).
const [localFonts, systemFamilies] = await Promise.all([
getMonospaceFonts(),
getAllSystemFontFamilies(),
]);
setSystemFamilies(systemFamilies);
// Combine default fonts with local fonts, deduplicate by id
const fontMap = new Map<string, TerminalFont>();

View File

@@ -64,4 +64,10 @@ export interface SftpStateOptions {
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
/**
* Global SSH keepalive settings, forwarded through to per-SFTP-connection
* keepalive resolution so a host that has opted into its own override
* is honored for SFTP browsing too (not just the terminal session).
*/
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}

View File

@@ -11,6 +11,7 @@ interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
leftTabs: { tabs: SftpPane[] };
@@ -44,6 +45,7 @@ export const useSftpConnections = ({
hosts,
keys,
identities,
terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
@@ -65,7 +67,7 @@ export const useSftpConnections = ({
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(

View File

@@ -1,12 +1,19 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
// identical whether or not the caller passes settings.
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
}
export const buildSftpHostCredentials = ({
@@ -14,7 +21,9 @@ export const buildSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
@@ -79,6 +88,7 @@ export const buildSftpHostCredentials = ({
) {
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -101,6 +111,8 @@ export const buildSftpHostCredentials = ({
}
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
}
@@ -129,6 +141,7 @@ export const buildSftpHostCredentials = ({
throw new Error("Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.");
}
const targetKeepalive = resolveHostKeepalive(host, globalKeepalive);
return {
hostname: host.hostname,
username: resolved.username,
@@ -144,6 +157,8 @@ export const buildSftpHostCredentials = ({
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
};
};
@@ -151,8 +166,9 @@ export const useSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities }),
[hosts, identities, keys],
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
[hosts, identities, keys, terminalSettings],
);

View File

@@ -24,6 +24,7 @@ export interface UsePortForwardingAutoStartOptions {
identities: Identity[];
proxyProfiles: ProxyProfile[];
groupConfigs: GroupConfig[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
@@ -103,6 +104,7 @@ export const usePortForwardingAutoStart = ({
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
@@ -110,6 +112,8 @@ export const usePortForwardingAutoStart = ({
const identitiesRef = useRef<Identity[]>(identities);
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
@@ -238,7 +242,7 @@ export const usePortForwardingAutoStart = ({
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current);
};
setReconnectCallback(handleReconnect);
@@ -304,6 +308,10 @@ export const usePortForwardingAutoStart = ({
updateStoredRuleStatus(rule.id, status, error);
},
true, // Enable reconnect for auto-start rules
// Read via ref so adjusting global keepalive after launch doesn't
// re-trigger the auto-start effect (its dep array is intentionally
// stable to fire once on vault init).
terminalSettingsRef.current,
);
}
};

View File

@@ -68,6 +68,7 @@ export interface UsePortForwardingStateResult {
identities: Identity[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => Promise<{ success: boolean; error?: string }>;
stopTunnel: (
ruleId: string,
@@ -387,11 +388,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
error?: string,
) => void,
enableReconnect = false,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);
}, enableReconnect, terminalSettings);
},
[setRuleStatus],
);

View File

@@ -51,7 +51,7 @@ import {
} from '../../domain/customKeyBindings';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
@@ -71,6 +71,28 @@ const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
const DEFAULT_FONT_FAMILY = 'menlo';
/**
* Migrate any terminal font id arriving from storage / IPC / sync to a
* safe value. If `raw` is a deprecated proportional id (pingfang-sc,
* microsoft-yahei, comic-sans-ms), persist the rewrite back to
* localStorage so subsequent ingest paths and cloud-sync uploads stop
* carrying it. Used by every place that reads STORAGE_KEY_TERM_FONT_FAMILY
* — initial useState init, rehydrateAllFromStorage, IPC notifySettings
* change listener, and cross-window storage event listener — so a
* single point of truth keeps deprecated ids from re-entering state.
*
* Returns null when there's nothing to apply (raw is empty); callers
* fall back to DEFAULT_FONT_FAMILY in that case.
*/
function migrateIncomingTerminalFontId(raw: string | null | undefined): string | null {
if (!raw) return null;
if (isDeprecatedPrimaryFontId(raw)) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, DEFAULT_FONT_FAMILY);
return DEFAULT_FONT_FAMILY;
}
return raw;
}
// Auto-detect default hotkey scheme based on platform
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -232,7 +254,10 @@ export const useSettingsState = () => {
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
});
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
@@ -512,7 +537,8 @@ export const useSettingsState = () => {
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
@@ -648,7 +674,8 @@ export const useSettingsState = () => {
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
setTerminalFontFamilyId(value);
const migrated = migrateIncomingTerminalFontId(value);
if (migrated) setTerminalFontFamilyId(migrated);
}
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
@@ -844,8 +871,9 @@ export const useSettingsState = () => {
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(e.newValue);
const migrated = migrateIncomingTerminalFontId(e.newValue);
if (migrated && migrated !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(migrated);
}
}
// Sync terminal font size from other windows

View File

@@ -174,6 +174,7 @@ export const useSftpState = (
hosts,
keys,
identities,
terminalSettings: options?.terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import {
ConnectionLog,
GroupConfig,
@@ -240,9 +241,15 @@ export const useVaultState = () => {
}, []);
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
setGroupConfigs(data);
// Sanitize on the write path too — applySyncPayload / importVaultData
// route legacy payloads through here, and without this step a saved
// pingfang-sc / comic-sans-ms override from an older client would
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = data.map(sanitizeGroupConfig);
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
return encryptGroupConfigs(data).then((enc) => {
return encryptGroupConfigs(cleaned).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -528,8 +535,9 @@ export const useVaultState = () => {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
const sanitizedGC = decryptedGC.map(sanitizeGroupConfig);
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -659,7 +667,7 @@ export const useVaultState = () => {
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(dec);
setGroupConfigs(dec.map(sanitizeGroupConfig));
});
return;
}

View File

@@ -561,6 +561,75 @@ test("applySyncPayload waits for async vault imports", async () => {
assert.equal(finished, true);
});
test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fallbackFont: "PingFang SC", fontLigatures: true }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.fallbackFont, "PingFang SC");
});
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fontLigatures: true }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal("fallbackFont" in termSettings, false);
});
test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", async () => {
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { fallbackFont: "Sarasa Mono SC" } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
});
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fallbackFont: "Microsoft YaHei UI" }),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { scrollback: 9999 } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
const parsed = JSON.parse(raw!);
assert.equal(parsed.fallbackFont, "Microsoft YaHei UI", "legacy payload must not wipe local fallbackFont");
assert.equal(parsed.scrollback, 9999);
});
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {

View File

@@ -159,7 +159,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard', 'showServerStats',
'serverStatsRefreshInterval', 'rendererType',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',

View File

@@ -8,6 +8,7 @@ import {
FolderPlus,
Forward,
Globe,
HeartPulse,
Key,
KeyRound,
Link2,
@@ -1806,6 +1807,72 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
{/* Per-host keepalive override */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<HeartPulse size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.keepalive")}</p>
</div>
<ToggleRow
label={t("hostDetails.keepalive.override")}
enabled={!!form.keepaliveOverride}
onToggle={() => {
const next = !form.keepaliveOverride;
update("keepaliveOverride", next);
// Seed sensible per-host defaults the first time the user
// turns the override on so the inputs aren't empty.
if (next) {
if (form.keepaliveInterval == null) update("keepaliveInterval", 0);
if (form.keepaliveCountMax == null) update("keepaliveCountMax", 3);
}
}}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.keepalive.desc")}
</p>
{form.keepaliveOverride && (
<div className="space-y-2 pt-1">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.interval")}</p>
<input
type="number"
min={0}
max={3600}
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
value={form.keepaliveInterval ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!Number.isFinite(v)) return;
if (v < 0 || v > 3600) return;
update("keepaliveInterval", v);
}}
/>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.countMax")}</p>
<input
type="number"
min={1}
max={100}
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
value={form.keepaliveCountMax ?? 3}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!Number.isFinite(v)) return;
if (v < 1 || v > 100) return;
update("keepaliveCountMax", v);
}}
/>
</div>
{(form.keepaliveInterval ?? 0) === 0 && (
<p className="text-xs text-muted-foreground break-words pl-1">
{t("hostDetails.keepalive.disabledHint")}
</p>
)}
</div>
)}
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">

View File

@@ -75,6 +75,7 @@ interface PortForwardingProps {
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const PortForwarding: React.FC<PortForwardingProps> = ({
@@ -88,6 +89,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
terminalSettings,
}) => {
const { t } = useI18n();
const {
@@ -169,6 +171,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}
},
rule.autoStart, // Enable reconnect for auto-start rules
terminalSettings,
);
// Show error from result only if not already shown
if (!result.success && result.error && !errorShown) {
@@ -186,7 +189,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t],
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t, terminalSettings],
);
// Stop a port forwarding tunnel

View File

@@ -71,6 +71,7 @@ interface SftpSidePanelProps {
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onRequestTerminalFocus?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
@@ -98,6 +99,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
setEditorWordWrap,
onGetTerminalCwd,
onRequestTerminalFocus,
terminalSettings,
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
@@ -119,7 +121,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const {
@@ -767,7 +770,11 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path;
prev.initialLocation?.path === next.initialLocation?.path &&
// Only the keepalive fields of terminalSettings affect SFTP connection
// resolution today; compare them directly rather than the whole object.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
SftpSidePanel.displayName = "SftpSidePanel";

View File

@@ -66,6 +66,7 @@ interface SftpViewProps {
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpViewInner: React.FC<SftpViewProps> = ({
@@ -84,6 +85,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keyBindings,
editorWordWrap,
setEditorWordWrap,
terminalSettings,
}) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
@@ -109,7 +111,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
// Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() => {
@@ -521,7 +524,12 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap;
prev.setEditorWordWrap === next.setEditorWordWrap &&
// Only the keepalive fields of terminalSettings affect SFTP connection
// resolution today; compare them directly rather than the whole object
// so unrelated terminal-setting changes don't tear the panel down.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
SftpView.displayName = "SftpView";

View File

@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
@@ -37,6 +37,7 @@ import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { toast } from "./ui/toast";
import { useAvailableFonts } from "../application/state/fontStore";
import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useCustomThemes } from "../application/state/customThemeStore";
@@ -709,8 +710,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
? host.fontFamily
: fontFamilyId;
const resolvedFontId = hostFontId || "menlo";
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
const selectedFont = availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0];
const platform: SupportedPlatform =
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
? "darwin"
: typeof navigator !== "undefined" && /Win/i.test(navigator.platform)
? "win32"
: "linux";
return composeFontFamilyStack({
primaryFamily: selectedFont.family,
userFallback: terminalSettings?.fallbackFont ?? "",
latinFontId: resolvedFontId,
platform,
});
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
const effectiveTheme = useMemo(() => {
// When "Follow Application Theme" is on and there's no active
@@ -1381,10 +1394,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!el) return;
const handleContextMenuCapture = (e: MouseEvent) => {
if (mouseTrackingRef.current) {
e.preventDefault();
e.stopImmediatePropagation();
if (!mouseTrackingRef.current) return;
e.preventDefault();
e.stopImmediatePropagation();
// stopImmediatePropagation blocks the event from reaching React's
// bubble-phase root listener, so the onContextMenu handler in
// TerminalContextMenu (which dispatches paste / select-word) never
// fires inside a mouse-tracking TUI. Without dispatching the user's
// chosen action here, right-click paste silently stops working in
// opencode, tmux with `mouse on`, vim with `set mouse=a`, etc. (#941).
// Middle-click still works because its auxclick listener lives in
// createXTermRuntime and isn't gated by mouseTracking.
const behavior = terminalSettingsRef.current?.rightClickBehavior;
if (behavior === 'paste') {
void terminalContextActionsRef.current?.onPaste?.();
} else if (behavior === 'select-word') {
terminalContextActionsRef.current?.onSelectWord?.();
}
// 'context-menu' is intentionally not handled — Radix opens the
// menu via its own pointerdown listener, which our capture handler
// does not intercept.
};
const handleMouseUpCapture = (e: MouseEvent) => {
@@ -1481,6 +1511,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
disableBracketedPasteRef,
scrollOnPasteRef,
});
// Kept fresh on every render so the mouseTracking capture handler at
// handleContextMenuCapture (which is bound once per sessionId) can
// still invoke the latest paste / select-word callbacks without
// re-binding on every action identity change. See #941.
const terminalContextActionsRef = useRef(terminalContextActions);
terminalContextActionsRef.current = terminalContextActions;
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
@@ -1842,6 +1878,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
statusDotTone,
)}
/>
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
<button
type="button"
className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
onClick={() => {
void navigator.clipboard.writeText(host.hostname).then(() => {
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
}).catch(() => {
toast.error(t("terminal.statusbar.copyHostname.error"));
});
}}
title={t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}
aria-label={t("terminal.statusbar.copyHostname.label")}
>
<Copy size={10} />
</button>
)}
</div>
{/* Server Stats Display */}
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (

View File

@@ -2394,6 +2394,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
onRequestTerminalFocus={refocusActiveTerminalSession}
terminalSettings={terminalSettings}
/>
);
})}

View File

@@ -107,7 +107,11 @@ const WorkspaceGroup: React.FC<{
);
};
const TrayPanelContent: React.FC = () => {
interface TrayPanelContentProps {
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
const { t } = useI18n();
const {
hideTrayPanel,
@@ -350,7 +354,7 @@ const TrayPanelContent: React.FC = () => {
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}, rule.autoStart, terminalSettings);
}
}}
className={cn(
@@ -411,7 +415,7 @@ const TrayPanel: React.FC = () => {
const settings = useSettingsState();
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent />
<TrayPanelContent terminalSettings={settings.terminalSettings} />
</I18nProvider>
);
};

View File

@@ -172,6 +172,7 @@ interface VaultViewProps {
// Optional: navigate to a specific section on mount or when changed
navigateToSection?: VaultSection | null;
onNavigateToSectionHandled?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const VaultViewInner: React.FC<VaultViewProps> = ({
@@ -222,6 +223,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
showOnlyUngroupedHostsInRoot,
navigateToSection,
onNavigateToSectionHandled,
terminalSettings,
}) => {
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement>(null);
@@ -2946,6 +2948,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Array.from(new Set([...customGroups, groupPath])),
)
}
terminalSettings={terminalSettings}
/>
)}
{/* Always render KnownHostsManager but hide with CSS to prevent unmounting */}
@@ -3277,7 +3280,13 @@ export const vaultViewAreEqual = (
prev.groupConfigs === next.groupConfigs &&
prev.terminalThemeId === next.terminalThemeId &&
prev.terminalFontSize === next.terminalFontSize &&
prev.navigateToSection === next.navigateToSection;
prev.navigateToSection === next.navigateToSection &&
// Only the keepalive fields of terminalSettings are forwarded to
// PortForwarding inside the vault, so compare them directly. Other
// terminal settings (fonts, themes, etc.) don't affect this subtree
// and we don't want to re-render for them.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
return isEqual;
};

View File

@@ -49,7 +49,7 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
disabled={!hasMessages}
title={t('ai.chat.exportConversation')}
>
@@ -59,18 +59,18 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
<DropdownContent
align="end"
sideOffset={6}
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-sm"
className="w-40 rounded-xl border border-border/60 bg-popover p-1.5 text-popover-foreground shadow-lg supports-[backdrop-filter]:bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm"
>
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/70">
{t('ai.chat.exportAs')}
</div>
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
<button
key={format}
onClick={() => handleExport(format)}
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-white/[0.04]"
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
<Icon size={13} className="shrink-0 text-muted-foreground" />
<span>{t(labelKey)}</span>
</button>
))}

View File

@@ -0,0 +1,153 @@
import React, { useMemo, useSyncExternalStore } from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
getFontAvailabilityVersion,
isFontInstalled,
subscribeFontAvailability,
} from '../../lib/fontAvailability';
const AUTO_SENTINEL = '__auto__';
interface CjkFontOption {
value: string;
/** i18n key looked up via t(). Use '' for the Auto sentinel. */
labelKey: string;
}
// Only true monospace CJK fonts. Proportional CJK fonts (PingFang SC,
// Microsoft YaHei UI, Hiragino Sans GB) render at non-2x widths and
// break terminal grid alignment — they are deliberately excluded here
// even though they are the OS defaults.
const OPTIONS: CjkFontOption[] = [
{ value: '', labelKey: 'settings.terminal.font.cjk.option.auto' },
{ value: 'Sarasa Mono SC', labelKey: 'settings.terminal.font.cjk.option.sarasaSC' },
{ value: 'Sarasa Mono TC', labelKey: 'settings.terminal.font.cjk.option.sarasaTC' },
{ value: 'Maple Mono CN', labelKey: 'settings.terminal.font.cjk.option.mapleCN' },
{ value: 'Source Han Mono SC', labelKey: 'settings.terminal.font.cjk.option.sourceHan' },
{ value: 'Noto Sans Mono CJK SC', labelKey: 'settings.terminal.font.cjk.option.notoCJK' },
{ value: 'LXGW WenKai Mono', labelKey: 'settings.terminal.font.cjk.option.lxgwWenkai' },
{ value: 'SimSun', labelKey: 'settings.terminal.font.cjk.option.simSun' },
];
interface Props {
value: string;
onChange: (next: string) => void;
className?: string;
disabled?: boolean;
}
export const TerminalCjkFontSelect: React.FC<Props> = ({
value,
onChange,
className,
disabled,
}) => {
const { t } = useI18n();
const matchedOption = OPTIONS.find((o) => o.value === value);
const radixValue = value === '' ? AUTO_SENTINEL : (matchedOption?.value ?? value);
const triggerLabel = matchedOption
? t(matchedOption.labelKey)
: value
? t('settings.terminal.font.cjk.option.legacy', { font: value })
: value;
// Subscribe to font availability so the filter re-evaluates after the
// Local Font Access API populates the authoritative install set
// asynchronously (otherwise the dropdown would show stale availability
// until the user manually changed `value`).
const availabilityVersion = useSyncExternalStore(
subscribeFontAvailability,
getFontAvailabilityVersion,
getFontAvailabilityVersion,
);
// "Auto" is always present; concrete fonts only appear when installed;
// the currently-selected value (if any) is also always shown so users
// can see and clear their setting even on a machine without the font.
// Legacy selections (e.g. "PingFang SC" saved before we dropped
// proportional fonts) are appended as a synthetic option with a
// "not recommended" label so the user can see them and re-pick.
const visibleOptions = useMemo(() => {
// The version is read here only so eslint-react-hooks sees it
// used; in practice we depend on it to invalidate this memo when
// setSystemFamilies bumps it (isFontInstalled below reads module
// state, so we need an explicit signal).
void availabilityVersion;
const filtered: Array<{ value: string; label: string }> = OPTIONS.filter(
(opt) =>
opt.value === '' ||
opt.value === value ||
isFontInstalled(opt.value),
).map((opt) => ({ value: opt.value, label: t(opt.labelKey) }));
if (value && !OPTIONS.some((o) => o.value === value)) {
filtered.push({
value,
label: t('settings.terminal.font.cjk.option.legacy', { font: value }),
});
}
return filtered;
}, [value, availabilityVersion, t]);
return (
<SelectPrimitive.Root
value={radixValue}
onValueChange={(next) => onChange(next === AUTO_SENTINEL ? '' : next)}
disabled={disabled}
>
<SelectPrimitive.Trigger
className={cn(
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
>
<SelectPrimitive.Value>
<span style={{ fontFamily: value ? `"${value}", monospace` : undefined }}>
{triggerLabel}
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[14rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{visibleOptions.map((opt) => (
<SelectPrimitive.Item
key={opt.value || AUTO_SENTINEL}
value={opt.value || AUTO_SENTINEL}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span style={{ fontFamily: opt.value ? `"${opt.value}", monospace` : undefined }}>
{opt.label}
</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export default TerminalCjkFontSelect;

View File

@@ -1,7 +1,14 @@
import React from 'react';
import React, { useMemo, useSyncExternalStore } from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../lib/utils';
import {
extractPrimaryFamily,
getFontAvailabilityVersion,
hasAuthoritativeData,
isFontInstalled,
subscribeFontAvailability,
} from '../../lib/fontAvailability';
import type { TerminalFont } from '../../infrastructure/config/fonts';
interface TerminalFontSelectProps {
@@ -21,6 +28,37 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
}) => {
const selectedFont = fonts.find(f => f.id === value);
// Subscribe to font availability so the filter re-evaluates after the
// Local Font Access API populates the authoritative install set
// asynchronously, even if the `fonts` prop ref hasn't changed.
const availabilityVersion = useSyncExternalStore(
subscribeFontAvailability,
getFontAvailabilityVersion,
getFontAvailabilityVersion,
);
// Hide fonts that aren't actually rendered on this machine so users
// don't pick a font and then see no visible change. The currently
// selected font is always shown so the user can read their setting.
//
// When the Local Font Access API has populated authoritative data,
// trust it: an empty or near-empty result means the user really has
// few monospace fonts (Layer 3 still gives at least one option via
// bundled Sarasa Mono SC). When canvas-only fallback is in play,
// we keep a safety net at length>=1 to avoid an empty dropdown if
// detection misfires.
const visibleFonts = useMemo(() => {
// Referenced so eslint-react-hooks sees the dep used; the real
// purpose is to invalidate this memo when setSystemFamilies bumps
// the version (isFontInstalled reads module state).
void availabilityVersion;
const filtered = fonts.filter(
(f) => f.id === value || isFontInstalled(extractPrimaryFamily(f.family)),
);
if (hasAuthoritativeData()) return filtered;
return filtered.length >= 1 ? filtered : fonts;
}, [fonts, value, availabilityVersion]);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
@@ -48,7 +86,7 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{fonts.map((font) => (
{visibleFonts.map((font) => (
<SelectPrimitive.Item
key={font.id}
value={font.id}

View File

@@ -22,6 +22,7 @@ import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal";
import { TerminalFontSelect } from "../TerminalFontSelect";
import { TerminalCjkFontSelect } from "../TerminalCjkFontSelect";
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
import type { TerminalTheme } from "../../../domain/models";
@@ -615,6 +616,17 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.font.cjk")}
description={t("settings.terminal.font.cjk.desc")}
>
<TerminalCjkFontSelect
value={terminalSettings.fallbackFont ?? ""}
onChange={(next) => updateTerminalSetting("fallbackFont", next)}
className="w-48"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.font.size")}
description={t("settings.terminal.font.size.desc")}
@@ -1034,6 +1046,24 @@ export default function SettingsTerminalTab(props: {
className="w-24"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.connection.keepaliveCountMax")}
description={t("settings.terminal.connection.keepaliveCountMax.desc")}
>
<Input
type="number"
min={1}
max={100}
value={terminalSettings.keepaliveCountMax}
onChange={(e) => {
const val = parseInt(e.target.value) || 1;
if (val >= 1 && val <= 100) {
updateTerminalSetting("keepaliveCountMax", val);
}
}}
className="w-24"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.connection.x11Display")}
description={t("settings.terminal.connection.x11Display.desc")}

View File

@@ -12,6 +12,7 @@ import {
import { resolveHostAuth } from "../../../domain/sshAuth";
import {
detectVendorFromSshVersion,
resolveHostKeepalive,
resolveTelnetPassword,
resolveTelnetPort,
resolveTelnetUsername,
@@ -470,6 +471,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.updateStatus("disconnected");
return;
}
const globalKeepalive = ctx.terminalSettings ?? { keepaliveInterval: 30, keepaliveCountMax: 10 };
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
@@ -512,6 +514,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
// Resolve keepalive for THIS hop. Each jump host carries its own
// override toggle, so a bastion that is a router (interval=0) can
// coexist with a cloud target host (interval=30) in the same chain.
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -534,6 +541,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
: undefined,
identityFilePaths: jumpIdentityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
@@ -662,6 +671,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
password?: string;
key?: SSHKey;
}): Promise<string> => {
// Resolve keepalive per-host: a host can opt into its own values
// (e.g. set interval=0 on an embedded device whose SSH stack
// doesn't reply to keepalive@openssh.com) while everything else
// inherits the cloud-friendly global setting.
const keepalive = resolveHostKeepalive(
ctx.host,
ctx.terminalSettings ?? { keepaliveInterval: 30, keepaliveCountMax: 10 },
);
return ctx.terminalBackend.startSSHSession({
sessionId: ctx.sessionId,
hostLabel: ctx.host.label,
@@ -687,7 +704,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
env: termEnv,
proxy: proxyConfig,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
keepaliveInterval: keepalive.interval,
keepaliveCountMax: keepalive.countMax,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths,
knownHosts: ctx.knownHosts,

View File

@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyGroupDefaults, resolveGroupDefaults } from "./groupConfig.ts";
import { applyGroupDefaults, resolveGroupDefaults, sanitizeGroupConfig } from "./groupConfig.ts";
import { resolveTelnetPassword, resolveTelnetUsername } from "./host.ts";
import type { GroupConfig, Host } from "./models.ts";
@@ -182,3 +182,29 @@ test("applyGroupDefaults continues to inherit empty ssh username from the group"
assert.equal(result.username, "group-ssh-user");
});
test("sanitizeGroupConfig migrates a deprecated fontFamily and clears the override flag", () => {
// Regression guard for codex P2 review on PR #940: groups saved with
// pingfang-sc / microsoft-yahei / comic-sans-ms must shed the
// override so member hosts inherit the global default instead of
// silently falling through to fonts[0] under an enabled override.
const before: GroupConfig = {
path: "team",
fontFamily: "pingfang-sc",
fontFamilyOverride: true,
};
const after = sanitizeGroupConfig(before);
assert.equal(after.fontFamily, undefined);
assert.equal(after.fontFamilyOverride, false);
});
test("sanitizeGroupConfig keeps a still-valid fontFamily untouched", () => {
const before: GroupConfig = {
path: "team",
fontFamily: "jetbrains-mono",
fontFamilyOverride: true,
};
const after = sanitizeGroupConfig(before);
assert.equal(after.fontFamily, "jetbrains-mono");
assert.equal(after.fontFamilyOverride, true);
});

View File

@@ -1,4 +1,15 @@
import type { GroupConfig, Host } from './models';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
/**
* Migrate deprecated primary-font ids out of a GroupConfig's
* font-override fields. Symmetrical to sanitizeHost; both run on load
* to keep the same proportional-font protection working for group
* defaults too.
*/
export function sanitizeGroupConfig(config: GroupConfig): GroupConfig {
return migrateDeprecatedFontOverride(config);
}
export interface ApplyGroupDefaultsOptions {
validProxyProfileIds?: ReadonlySet<string>;

View File

@@ -4,9 +4,11 @@ import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import {
normalizePrimaryTelnetState,
resolveHostKeepalive,
resolveTelnetPort,
resolveTelnetPassword,
resolveTelnetUsername,
sanitizeHost,
upsertHostById,
} from "./host.ts";
@@ -130,3 +132,83 @@ test("resolveTelnetPort uses primary telnet port fallback", () => {
telnetPort: undefined,
})), 2325);
});
test("sanitizeHost migrates a deprecated fontFamily and clears the override flag", () => {
// Regression guard for codex P2 review on PR #940: hosts saved with
// pingfang-sc / microsoft-yahei / comic-sans-ms in fontFamily must
// have the override dropped so they fall back to the global default
// instead of silently rendering the wrong font while still claiming
// an override is active.
const before = makeHost({
fontFamily: "comic-sans-ms",
fontFamilyOverride: true,
});
const after = sanitizeHost(before);
assert.equal(after.fontFamily, undefined);
assert.equal(after.fontFamilyOverride, false);
});
test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
const before = makeHost({
fontFamily: "fira-code",
fontFamilyOverride: true,
});
const after = sanitizeHost(before);
assert.equal(after.fontFamily, "fira-code");
assert.equal(after.fontFamilyOverride, true);
});
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
test("resolveHostKeepalive falls back to global when override is not set", () => {
const host = makeHost();
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 30, countMax: 10, source: "global" },
);
});
test("resolveHostKeepalive falls back to global when override is explicitly false", () => {
const host = makeHost({
keepaliveOverride: false,
keepaliveInterval: 0,
keepaliveCountMax: 3,
});
// Override flag is the gate; the host's stored values stay parked and
// unused so toggling the flag back on later restores them.
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 30, countMax: 10, source: "global" },
);
});
test("resolveHostKeepalive uses host values when override is true", () => {
const host = makeHost({
keepaliveOverride: true,
keepaliveInterval: 0,
keepaliveCountMax: 3,
});
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 0, countMax: 3, source: "host" },
);
});
test("resolveHostKeepalive lets each field fall back independently", () => {
// Override on, but only `interval` set on the host: inherit global countMax.
assert.deepEqual(
resolveHostKeepalive(
makeHost({ keepaliveOverride: true, keepaliveInterval: 5 }),
GLOBAL_KEEPALIVE,
),
{ interval: 5, countMax: 10, source: "host" },
);
// Override on, but only countMax set: inherit global interval.
assert.deepEqual(
resolveHostKeepalive(
makeHost({ keepaliveOverride: true, keepaliveCountMax: 50 }),
GLOBAL_KEEPALIVE,
),
{ interval: 30, countMax: 50, source: "host" },
);
});

View File

@@ -1,4 +1,5 @@
import { Host } from './models';
import { Host, TerminalSettings } from './models';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
export const LINUX_DISTRO_OPTIONS = [
'linux',
@@ -189,6 +190,40 @@ export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
: [...hosts, host];
};
export interface ResolvedKeepalive {
interval: number; // Seconds; 0 = disabled
countMax: number; // Unanswered keepalives before declaring dead
source: 'host' | 'global';
}
/**
* Decide which SSH keepalive values to apply to a connection. A host can opt
* into its own values via `keepaliveOverride === true` — useful when a
* specific device (older router / switch / NOKIA / ALCATEL SSH stack) doesn't
* reply to keepalive@openssh.com and the global aggressive setting would
* cause the session to be declared dead after a handful of unanswered probes.
* When the override is off (the default), the host inherits the global
* TerminalSettings values which are tuned for cloud / NAT'd hosts.
*
* Each field falls back independently: a host can override only the interval
* while still inheriting the global countMax, and vice versa.
*/
export const resolveHostKeepalive = (
host: Pick<Host, 'keepaliveOverride' | 'keepaliveInterval' | 'keepaliveCountMax'>,
globalSettings: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>,
): ResolvedKeepalive => {
const globalInterval = globalSettings.keepaliveInterval;
const globalCountMax = globalSettings.keepaliveCountMax;
if (host.keepaliveOverride !== true) {
return { interval: globalInterval, countMax: globalCountMax, source: 'global' };
}
return {
interval: host.keepaliveInterval ?? globalInterval,
countMax: host.keepaliveCountMax ?? globalCountMax,
source: 'host',
};
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);
@@ -199,8 +234,9 @@ export const sanitizeHost = (host: Host): Host => {
: host.distroMode === 'auto'
? 'auto'
: undefined;
const migrated = migrateDeprecatedFontOverride(host);
return {
...host,
...migrated,
hostname: cleanHostname,
distro: cleanDistro,
distroMode: cleanDistroMode,

View File

@@ -129,6 +129,15 @@ export interface Host {
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
// Per-host SSH keepalive override. When `keepaliveOverride === true`, the
// host uses its own `keepaliveInterval` / `keepaliveCountMax` instead of
// inheriting the global TerminalSettings values. Lets a user keep an
// aggressive cloud-friendly keepalive globally while disabling it for a
// specific router / embedded device whose SSH stack doesn't reply to
// OpenSSH keepalive global requests (issue #581 / #939).
keepaliveInterval?: number; // Seconds; 0 = disabled
keepaliveCountMax?: number; // Unanswered keepalives before declaring dead
keepaliveOverride?: boolean;
// What the Backspace key sends: undefined = xterm default (no interception), 'ctrl-h' = ^H (0x08)
backspaceBehavior?: 'ctrl-h';
// Local SSH key file paths (from SSH config IdentityFile or user-added)
@@ -503,6 +512,7 @@ export interface TerminalSettings {
// SSH Connection
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
keepaliveCountMax: number; // Unanswered keepalives before declaring the connection dead
x11Display: string; // Optional local X11 DISPLAY override (empty = use system DISPLAY/default)
// Mosh Connection
@@ -653,7 +663,13 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
localShell: '', // Empty = use system default
localStartDir: '', // Empty = use home directory
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
// Cloud-friendly defaults: 30s interval keeps NAT/LB state tables alive,
// and 10 unanswered keepalives provides headroom for brief network glitches
// before declaring the session dead (~5 min). Hosts whose SSH stack doesn't
// reply to keepalive@openssh.com (older routers/switches) should set their
// own per-host keepaliveOverride and dial these values down.
keepaliveInterval: 30,
keepaliveCountMax: 10,
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showServerStats: true, // Show server stats by default

View File

@@ -82,6 +82,8 @@ async function startPortForward(event, payload) {
jumpHosts = [],
identityFilePaths,
legacyAlgorithms,
keepaliveInterval: resolvedKeepaliveInterval,
keepaliveCountMax: resolvedKeepaliveCountMax,
} = payload;
const conn = new SSHClient();
@@ -110,12 +112,26 @@ async function startPortForward(event, payload) {
}
};
// Keepalive policy:
// - positive value: honor it
// - explicit 0: truly disabled (host opted out via per-host override —
// a router/switch that doesn't reply to keepalive@openssh.com would
// otherwise be killed by ssh2 after countMax unanswered probes)
// - undefined: legacy caller path, fall back to 10s/3 so an idle
// forwarded TCP tunnel doesn't get dropped by NAT state tables.
const tunnelKeepaliveMs = resolvedKeepaliveInterval == null
? 10000
: (resolvedKeepaliveInterval > 0 ? resolvedKeepaliveInterval * 1000 : 0);
const tunnelKeepaliveCountMax = resolvedKeepaliveInterval == null
? 3
: (resolvedKeepaliveInterval > 0 ? (resolvedKeepaliveCountMax ?? 3) : 0);
const connectOpts = {
host: hostname,
port: port,
username: username || 'root',
readyTimeout: 120000, // 2 minutes for 2FA input
keepaliveInterval: 10000,
keepaliveInterval: tunnelKeepaliveMs,
keepaliveCountMax: tunnelKeepaliveCountMax,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildAlgorithms(legacyAlgorithms),

View File

@@ -923,14 +923,33 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
// Set to 0 (unlimited) since complex operations add many temp listeners
conn.setMaxListeners(0);
// Per-hop keepalive. The renderer's resolver returns either a positive
// number (use it) or 0 (the host explicitly opted out, e.g. a router
// whose SSH stack doesn't reply to keepalive@openssh.com). Only when
// BOTH the per-hop and the target-call fields are undefined do we
// fall back to 10s/3 — that path exists for older serializers that
// pre-date per-host plumbing, preserving the #669 idle-NAT protection
// for callers that haven't yet been updated.
const hopInterval = jump.keepaliveInterval != null
? jump.keepaliveInterval
: options.keepaliveInterval;
const hopCountMax = jump.keepaliveCountMax != null
? jump.keepaliveCountMax
: options.keepaliveCountMax;
const hopIntervalMs = hopInterval == null
? 10000
: (hopInterval > 0 ? hopInterval * 1000 : 0);
const hopCountMaxEffective = hopInterval == null
? 3
: (hopInterval > 0 ? (hopCountMax ?? 3) : 0);
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
keepaliveInterval: 10000,
keepaliveCountMax: 3,
keepaliveInterval: hopIntervalMs,
keepaliveCountMax: hopCountMaxEffective,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
@@ -1431,14 +1450,20 @@ async function openSftp(event, options) {
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
readyTimeout: 120000, // 2 minutes for 2FA input
// Keep SFTP sessions alive while the panel is idle. Without SSH-level
// keepalive packets the connection sits with zero data flow while the
// user is just browsing files, and NAT/firewall state tables drop the
// idle TCP connection after ~30-60s (the exact symptom of #669).
// Honor an explicitly configured positive keepaliveInterval (seconds);
// otherwise default to 10s, matching the SFTP jump host path below.
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
// Keepalive policy:
// - positive value: honor it (in seconds, convert to ms)
// - explicit 0: truly disabled (host opted out via per-host override —
// critical for routers/switches that don't reply to keepalive
// @openssh.com and would otherwise be killed by ssh2 after countMax
// unanswered probes)
// - undefined: legacy caller path, fall back to 10s/3 so an idle SFTP
// browse over a NAT doesn't drop (the original #669 protection)
keepaliveInterval: options.keepaliveInterval == null
? 10000
: (options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0),
keepaliveCountMax: options.keepaliveInterval == null
? 3
: (options.keepaliveInterval > 0 ? (options.keepaliveCountMax ?? 3) : 0),
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};

View File

@@ -455,16 +455,23 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
options._tunnelRef.chainConnections = connections;
}
// Per-hop keepalive. Each jump entry already carries its own resolved
// interval/countMax (see resolveHostKeepalive in domain/host.ts), so
// a chain with a router as the bastion and a cloud host at the end
// can have keepalive=0 on the bastion and the cloud-friendly values
// on the final target — without one stepping on the other. We fall
// back to the target-call options for backward compat with older
// serializers that don't populate the per-hop fields yet.
const hopInterval = jump.keepaliveInterval ?? options.keepaliveInterval ?? 0;
const hopCountMax = jump.keepaliveCountMax ?? options.keepaliveCountMax ?? 10;
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
// 0 = disabled (no keepalive packets sent)
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0,
keepaliveCountMax: options.keepaliveInterval > 0 ? 3 : 0,
keepaliveInterval: hopInterval > 0 ? hopInterval * 1000 : 0,
keepaliveCountMax: hopInterval > 0 ? hopCountMax : 0,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildAlgorithms(options.legacyAlgorithms),
@@ -737,10 +744,11 @@ async function startSSHSession(event, options) {
username: options.username || "root",
// `readyTimeout` covers the entire connection + authentication flow in ssh2.
readyTimeout: 20000, // Fast failure for non-interactive auth
// Use user-configured keepalive interval (in seconds -> convert to ms)
// 0 = disabled (no keepalive packets sent)
// Resolved keepalive (caller decides whether host override or global
// applies). interval is in seconds; 0 means truly disabled, so
// countMax also goes to 0 to skip ssh2's dead-connection check.
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0,
keepaliveCountMax: options.keepaliveInterval > 0 ? 3 : 0,
keepaliveCountMax: options.keepaliveInterval > 0 ? (options.keepaliveCountMax ?? 10) : 0,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildAlgorithms(options.legacyAlgorithms),

10
global.d.ts vendored
View File

@@ -47,6 +47,10 @@ declare global {
label?: string; // Display label for UI
proxy?: NetcattyProxyConfig;
identityFilePaths?: string[];
// Resolved keepalive for THIS hop (caller has already applied host
// override / global fallback). interval in seconds, 0 = disabled.
keepaliveInterval?: number;
keepaliveCountMax?: number;
}
// Host key information for verification
@@ -90,6 +94,8 @@ declare global {
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
// Unanswered keepalives before ssh2 declares the connection dead
keepaliveCountMax?: number;
// Enable legacy SSH algorithms for older network equipment
legacyAlgorithms?: boolean;
// Use sudo for SFTP server
@@ -139,6 +145,10 @@ declare global {
jumpHosts?: NetcattyJumpHost[];
identityFilePaths?: string[];
legacyAlgorithms?: boolean;
// Resolved keepalive for the target connection (caller has already
// applied host override / global fallback). interval in seconds.
keepaliveInterval?: number;
keepaliveCountMax?: number;
}
interface PortForwardResult {

View File

@@ -2,9 +2,10 @@
/* Bundled icon-only fallback so terminals show Nerd Font glyphs (powerline,
devicons, etc.) regardless of which base font the user picks. The font is
referenced last in the fontFamily fallback chain (see withCjkFallback in
infrastructure/config/fonts.ts) — base text comes from the user's chosen
font, missing PUA glyphs fall through to this face.
referenced near the end of the fontFamily fallback chain composed by
composeFontFamilyStack() in infrastructure/config/cjkFonts.ts — base text
comes from the user's chosen font, missing PUA glyphs fall through to
this face.
Source: https://github.com/ryanoasis/nerd-fonts (NerdFontsSymbolsOnly,
v3.4.0). License: MIT — see public/fonts/SymbolsNerdFont-LICENSE.txt. */
@@ -20,6 +21,26 @@
font-display: block;
}
/* Bundled true-monospace CJK fallback. macOS ships only proportional CJK
fonts (PingFang, Hiragino) whose glyphs aren't designed to fit a
terminal's 2x cell grid — see #931. Sarasa Mono SC (Iosevka + Source
Han Sans, OFL-1.1) is a 2:1 metrically-correct CJK monospace and
becomes the per-OS default + per-Latin-font recommended pairing in
cjkFonts.ts. Subsetted woff2 (~4.8 MB) covers ASCII, CJK Unified
Ideographs (main block), Hiragana/Katakana, common punctuation and
symbols; rarer Ext-A/B characters fall through to the system fallback
stack.
Source: https://github.com/be5invis/Sarasa-Gothic (v1.0.37, OFL-1.1).
License: see public/fonts/SarasaMono-LICENSE.txt. */
@font-face {
font-family: "Sarasa Mono SC";
src: url("/fonts/SarasaMonoSC-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* ============================================
Tailwind CSS v4 Theme Configuration
============================================ */

View File

@@ -0,0 +1,249 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
composeFontFamilyStack,
getDefaultCjkFallback,
getRecommendedCjkFor,
splitFontFamilyList,
CJK_SYSTEM_FALLBACK_STACK,
} from './cjkFonts';
describe('composeFontFamilyStack', () => {
it('puts the primary font first', () => {
const stack = composeFontFamilyStack({
primaryFamily: 'Menlo, monospace',
userFallback: '',
latinFontId: 'menlo',
platform: 'darwin',
});
assert.match(stack, /^Menlo,\s*/);
});
it('inserts user fallback right after primary when provided', () => {
const stack = composeFontFamilyStack({
primaryFamily: '"Fira Code", monospace',
userFallback: 'Sarasa Mono SC',
latinFontId: 'fira-code',
platform: 'darwin',
});
const firaIdx = stack.indexOf('Fira Code');
const userIdx = stack.indexOf('Sarasa Mono SC');
assert.ok(firaIdx >= 0 && userIdx > firaIdx, 'user fallback after primary');
});
it('uses per-Latin-font recommended CJK when user fallback is empty', () => {
const stack = composeFontFamilyStack({
primaryFamily: '"Cascadia Code", monospace',
userFallback: '',
latinFontId: 'cascadia-code',
platform: 'win32',
});
// Cascadia Code now recommends Sarasa Mono SC (true monospace).
assert.match(stack, /Sarasa Mono SC/);
});
it('falls back to OS default when Latin font has no recommendation', () => {
const stack = composeFontFamilyStack({
primaryFamily: '"Unknown Font", monospace',
userFallback: '',
latinFontId: 'unknown',
platform: 'darwin',
});
// macOS no-recommendation default is now Sarasa Mono SC (bundled).
assert.match(stack, /Sarasa Mono SC/);
});
it('quotes multi-word user fallback names', () => {
const stack = composeFontFamilyStack({
primaryFamily: 'Menlo, monospace',
userFallback: 'Source Han Mono SC',
latinFontId: 'menlo',
platform: 'linux',
});
assert.match(stack, /"Source Han Mono SC"/);
});
it('does not duplicate identical fallback entries', () => {
// User explicitly picks the same font the per-font pairing would,
// and that font also lives in the system stack — should appear once.
const stack = composeFontFamilyStack({
primaryFamily: '"Cascadia Code", monospace',
userFallback: 'Sarasa Mono SC',
latinFontId: 'cascadia-code',
platform: 'win32',
});
const matches = stack.match(/Sarasa Mono SC/g) || [];
assert.equal(matches.length, 1);
});
it('inserts JetBrains Mono as Latin-only fallback right after the primary family', () => {
const stack = composeFontFamilyStack({
primaryFamily: 'Menlo',
userFallback: '',
latinFontId: 'menlo',
platform: 'darwin',
});
const families = stack.split(',').map((s) => s.trim().replace(/^"|"$/g, ''));
assert.equal(families[0], 'Menlo');
assert.equal(families[1], 'JetBrains Mono');
});
it('Latin fallback (JetBrains Mono) precedes every CJK family', () => {
// Regression guard for codex P1 review on PR #940 (first round):
// when the primary font isn't installed, Latin glyphs must fall to
// a Latin-only monospace face — NOT a CJK font's full-width Latin
// variant — to keep xterm's fixed cell grid aligned. JetBrains Mono
// is bundled via @fontsource and contains no CJK glyphs, so it
// catches Latin while letting CJK glyphs flow past.
const stack = composeFontFamilyStack({
primaryFamily: '"Fira Code", monospace',
userFallback: 'LXGW WenKai Mono',
latinFontId: 'fira-code',
platform: 'darwin',
});
const jbIdx = stack.indexOf('JetBrains Mono');
const sarasaIdx = stack.indexOf('Sarasa Mono SC');
const userFallbackIdx = stack.indexOf('LXGW WenKai Mono');
const simSunIdx = stack.indexOf('SimSun');
assert.ok(jbIdx > 0, 'JetBrains Mono must appear in the stack');
assert.ok(jbIdx < userFallbackIdx, 'JetBrains Mono before user CJK');
assert.ok(jbIdx < sarasaIdx, 'JetBrains Mono before Sarasa system fallback');
assert.ok(jbIdx < simSunIdx, 'JetBrains Mono before SimSun system fallback');
});
it('preserves a quoted primary family name that contains a comma', () => {
// Regression guard for codex P2 review on PR #940: when the primary
// family is something like `"Foo, Inc. Mono"`, the composed stack
// must keep that token intact rather than splitting on the internal
// comma and emitting fragmented pieces.
const stack = composeFontFamilyStack({
primaryFamily: '"Foo, Inc. Mono", monospace',
userFallback: '',
latinFontId: 'foo-inc-mono',
platform: 'darwin',
});
assert.ok(
stack.includes('"Foo, Inc. Mono"'),
'quoted family with comma stays a single token',
);
assert.ok(
!stack.includes('"Foo,') || stack.includes('"Foo, Inc. Mono"'),
'must not produce a dangling `"Foo,` fragment',
);
});
it('user-chosen CJK fallback precedes generic monospace', () => {
// Regression guard for codex P1 review on PR #940 (second round):
// generic `monospace` on macOS Chrome resolves Chinese glyphs to
// PingFang via Chromium's CJK system fallback. If `monospace`
// appeared in the chain BEFORE the user's CJK pick, CSS per-glyph
// fallback would stop at monospace for CJK characters and never
// consult the user's choice, silently nullifying the CJK picker.
const stack = composeFontFamilyStack({
primaryFamily: '"Fira Code", monospace',
userFallback: 'LXGW WenKai Mono',
latinFontId: 'fira-code',
platform: 'darwin',
});
const userFallbackIdx = stack.indexOf('LXGW WenKai Mono');
// Match `monospace` as a standalone token (after the comma+space).
const monospaceIdx = stack.lastIndexOf(', monospace');
assert.ok(userFallbackIdx > 0, 'user CJK must appear');
assert.ok(monospaceIdx > userFallbackIdx, 'user CJK must come before generic monospace');
});
it('explicit user fallback overrides the per-font recommendation', () => {
const stack = composeFontFamilyStack({
primaryFamily: '"JetBrains Mono", monospace',
userFallback: 'LXGW WenKai Mono',
latinFontId: 'jetbrains-mono',
platform: 'darwin',
});
// User chose LXGW WenKai Mono; the JetBrains Mono recommendation
// (Sarasa Mono SC) should be suppressed, so Sarasa only shows up
// later in the system fallback stack, AFTER the user choice.
const userIdx = stack.indexOf('LXGW WenKai Mono');
const sarasaIdx = stack.indexOf('Sarasa Mono SC');
assert.ok(userIdx >= 0);
assert.ok(sarasaIdx > userIdx, 'system Sarasa appears after explicit user choice');
});
});
describe('getDefaultCjkFallback', () => {
it('returns SimSun on Windows (always installed, monospace)', () => {
assert.equal(getDefaultCjkFallback('win32'), 'SimSun');
});
it('returns Sarasa Mono SC on macOS (bundled by app)', () => {
assert.equal(getDefaultCjkFallback('darwin'), 'Sarasa Mono SC');
});
it('returns Noto Sans Mono CJK SC on Linux', () => {
assert.equal(getDefaultCjkFallback('linux'), 'Noto Sans Mono CJK SC');
});
it('never returns a known proportional font', () => {
const proportional = ['PingFang SC', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Hiragino Sans GB'];
for (const platform of ['darwin', 'win32', 'linux'] as const) {
const v = getDefaultCjkFallback(platform);
assert.ok(!proportional.includes(v), `${platform} default ${v} must not be proportional`);
}
});
});
describe('getRecommendedCjkFor', () => {
it('returns null for unknown fonts', () => {
assert.equal(getRecommendedCjkFor('unknown-font-id', 'darwin'), null);
});
it('returns a non-empty string for known fonts', () => {
const v = getRecommendedCjkFor('jetbrains-mono', 'darwin');
assert.ok(v && v.length > 0);
});
});
describe('splitFontFamilyList', () => {
it('splits a simple comma-separated list', () => {
assert.deepEqual(
splitFontFamilyList('Menlo, monospace'),
['Menlo', 'monospace'],
);
});
it('keeps quoted family names with commas intact', () => {
// Regression guard for codex P2 review on PR #940: a font family
// name like `"Foo, Inc. Mono"` is a single token in CSS, not two.
assert.deepEqual(
splitFontFamilyList('"Foo, Inc. Mono", monospace'),
['"Foo, Inc. Mono"', 'monospace'],
);
});
it('handles a single unquoted name', () => {
assert.deepEqual(splitFontFamilyList('Iosevka'), ['Iosevka']);
});
it('handles single quotes too', () => {
assert.deepEqual(
splitFontFamilyList("'Foo, Inc.', serif"),
["'Foo, Inc.'", 'serif'],
);
});
it('drops empty segments produced by double commas', () => {
assert.deepEqual(
splitFontFamilyList('Menlo,, monospace'),
['Menlo', 'monospace'],
);
});
});
describe('CJK_SYSTEM_FALLBACK_STACK', () => {
it('contains true-monospace CJK fonts only', () => {
assert.match(CJK_SYSTEM_FALLBACK_STACK, /Sarasa Mono SC/);
assert.match(CJK_SYSTEM_FALLBACK_STACK, /Noto Sans Mono CJK SC/);
assert.match(CJK_SYSTEM_FALLBACK_STACK, /SimSun/);
});
it('does not include known proportional CJK fonts', () => {
assert.doesNotMatch(CJK_SYSTEM_FALLBACK_STACK, /PingFang SC/);
assert.doesNotMatch(CJK_SYSTEM_FALLBACK_STACK, /Microsoft YaHei UI/);
assert.doesNotMatch(CJK_SYSTEM_FALLBACK_STACK, /Hiragino Sans GB/);
});
});

View File

@@ -0,0 +1,183 @@
export type SupportedPlatform = 'darwin' | 'win32' | 'linux' | (string & {});
// True monospace CJK fonts only. Proportional fonts (PingFang SC,
// Microsoft YaHei UI, Hiragino Sans GB) render at non-2x widths in a
// terminal grid — including them here visibly broke alignment for users
// whose primary font lacked CJK glyphs. They are intentionally absent.
const CJK_SYSTEM_FALLBACK_FONTS = [
'"Sarasa Mono SC"',
'"Sarasa Mono TC"',
'"Maple Mono CN"',
'"LXGW WenKai Mono"',
'"Noto Sans Mono CJK SC"',
'"Source Han Mono SC"',
'"NSimSun"',
'"SimSun"',
];
export const CJK_SYSTEM_FALLBACK_STACK = CJK_SYSTEM_FALLBACK_FONTS.join(', ');
const NERD_FONT_FALLBACK_FONTS = [
'"Symbols Nerd Font Mono"',
'"Symbols Nerd Font"',
];
// Per-OS default CJK font when user hasn't explicitly set fallbackFont
// AND the current Latin font has no recommended pairing.
// All choices are TRUE monospace fonts that keep the terminal grid
// aligned. macOS has no system-installed monospace CJK font, so we
// reference Sarasa Mono SC which netcatty bundles as a webfont.
export function getDefaultCjkFallback(platform: SupportedPlatform): string {
if (platform === 'win32') return 'SimSun';
if (platform === 'darwin') return 'Sarasa Mono SC';
return 'Noto Sans Mono CJK SC';
}
// Every entry must point at a TRUE monospace CJK font. Sarasa Mono SC
// is the safest universal choice because netcatty bundles it via
// @font-face, so it works even on machines without other CJK monospace
// fonts installed.
const PER_FONT_CJK_PAIRING: Record<string, string> = {
'fira-code': 'Sarasa Mono SC',
'fira-mono': 'Sarasa Mono SC',
'jetbrains-mono': 'Sarasa Mono SC',
'cascadia-code': 'Sarasa Mono SC',
'cascadia-mono': 'Sarasa Mono SC',
'source-code-pro': 'Source Han Mono SC',
'ibm-plex-mono': 'Sarasa Mono SC',
'iosevka': 'Sarasa Mono SC',
'ioskeley-mono': 'Sarasa Mono SC',
'mononoki': 'Sarasa Mono SC',
'menlo': 'Sarasa Mono SC',
'monaco': 'Sarasa Mono SC',
'consolas': 'Sarasa Mono SC',
'courier-new': 'Sarasa Mono SC',
'dejavu-sans-mono':'Noto Sans Mono CJK SC',
'liberation-mono': 'Noto Sans Mono CJK SC',
'inconsolata': 'Noto Sans Mono CJK SC',
'victor-mono': 'Sarasa Mono SC',
'roboto-mono': 'Noto Sans Mono CJK SC',
'space-mono': 'Sarasa Mono SC',
'hack': 'Sarasa Mono SC',
'ubuntu-mono': 'Noto Sans Mono CJK SC',
'go-mono': 'Sarasa Mono SC',
};
export function getRecommendedCjkFor(
latinFontId: string,
platform: SupportedPlatform,
): string | null {
void platform;
return PER_FONT_CJK_PAIRING[latinFontId] ?? null;
}
/**
* Split a CSS font-family list on commas that are OUTSIDE quoted family
* names. CSS permits commas inside quoted family names (e.g.
* `"Foo, Inc. Mono"`); a naive `string.split(',')` would tokenize that
* into broken pieces like `"Foo` and `Inc. Mono"`. Exported so other
* font-parsing call sites (extractPrimaryFamily, etc.) share the same
* rules.
*/
export function splitFontFamilyList(css: string): string[] {
const tokens: string[] = [];
let buf = '';
let quote: '"' | "'" | null = null;
for (let i = 0; i < css.length; i++) {
const c = css[i];
if (quote) {
buf += c;
if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'") {
buf += c;
quote = c;
continue;
}
if (c === ',') {
const trimmed = buf.trim();
if (trimmed) tokens.push(trimmed);
buf = '';
continue;
}
buf += c;
}
const tail = buf.trim();
if (tail) tokens.push(tail);
return tokens;
}
function quoteIfNeeded(family: string): string {
const trimmed = family.trim();
if (!trimmed) return '';
if (trimmed === 'monospace') return trimmed;
if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed;
if (trimmed.includes(',')) return trimmed;
if (/\s/.test(trimmed)) return `"${trimmed}"`;
return trimmed;
}
interface ComposeArgs {
primaryFamily: string;
userFallback: string;
latinFontId: string;
platform: SupportedPlatform;
}
export function composeFontFamilyStack(args: ComposeArgs): string {
const { primaryFamily, userFallback, latinFontId, platform } = args;
const userFallbackQuoted = userFallback.trim() ? quoteIfNeeded(userFallback) : null;
const recommended = userFallbackQuoted
? null
: (getRecommendedCjkFor(latinFontId, platform) ?? getDefaultCjkFallback(platform));
const recommendedQuoted = recommended ? quoteIfNeeded(recommended) : null;
const seen = new Set<string>();
const pieces: string[] = [];
const push = (item: string | null | undefined) => {
if (!item) return;
const key = item.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
pieces.push(item);
};
// Quote-aware split so a family name like `"Foo, Inc. Mono"` keeps
// its comma intact instead of being shredded into `"Foo` / `Inc. Mono"`.
for (const p of splitFontFamilyList(primaryFamily)) {
if (p.toLowerCase() === 'monospace') continue;
push(p);
}
// Latin-only fallback (bundled via @fontsource/jetbrains-mono in
// index.tsx). Catches Latin glyphs when the primary font isn't
// installed without intercepting CJK glyphs the way the bare
// `monospace` generic would on macOS Chrome (where the generic
// monospace pulls in PingFang via system CJK fallback, masking the
// user's CJK font choice).
//
// Per-glyph CSS fallback then behaves as intended:
// - Latin chars: primary (if installed) → JetBrains Mono. Cells
// stay aligned because JetBrains Mono is true monospace.
// - CJK chars: primary (no) → JetBrains Mono (no CJK glyphs) →
// user-chosen CJK font (or per-Latin-font recommendation) →
// system CJK stack.
// - Nerd PUA: all of the above (none have PUA) → Nerd Font stack.
push('"JetBrains Mono"');
push(userFallbackQuoted);
push(recommendedQuoted);
for (const sys of CJK_SYSTEM_FALLBACK_FONTS) push(sys);
for (const nerd of NERD_FONT_FALLBACK_FONTS) push(nerd);
// Final safety net only — should rarely be reached because JetBrains
// Mono covers Latin and the CJK stack covers Chinese glyphs. Kept
// for the edge case where bundled fonts fail to load.
push('monospace');
return pieces.join(', ');
}

View File

@@ -0,0 +1,69 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { TERMINAL_FONTS } from './fonts';
/**
* Proportional (non-monospace) fonts must never appear in the terminal
* primary font dropdown. They produce broken cell-grid alignment because
* xterm.js samples cell width from a single probe glyph, and a font with
* variable-width Latin glyphs renders other characters with inconsistent
* widths around (or beyond) that cell.
*/
const KNOWN_PROPORTIONAL_FONTS = [
// CJK system fonts — proportional sans-serif designed for body text.
'PingFang SC',
'PingFang TC',
'PingFang HK',
'Microsoft YaHei',
'Microsoft YaHei UI',
'Hiragino Sans GB',
'Hiragino Sans',
'Heiti SC',
'Heiti TC',
// Latin proportional fonts that get mistakenly listed as "terminal
// fonts". Comic Sans MS was historically in this dropdown labeled
// "non-traditional terminal font" — picking it produced bloated cell
// widths because Comic Sans is a handwriting-style proportional face.
'Comic Sans MS',
'Arial',
'Helvetica',
'Times New Roman',
'Times',
'Georgia',
'Verdana',
'Trebuchet MS',
'Tahoma',
];
describe('TERMINAL_FONTS dropdown contents', () => {
it('does not list any known proportional font as a primary choice', () => {
for (const banned of KNOWN_PROPORTIONAL_FONTS) {
const matches = TERMINAL_FONTS.filter((f) =>
f.name === banned ||
f.family.includes(`"${banned}"`) ||
f.family.split(',')[0].trim() === banned,
);
assert.deepEqual(
matches,
[],
`${banned} must not appear in TERMINAL_FONTS — it is proportional and breaks terminal grid alignment`,
);
}
});
it('every entry has a non-empty id, name, and family', () => {
for (const font of TERMINAL_FONTS) {
assert.ok(font.id.length > 0, `${JSON.stringify(font)} missing id`);
assert.ok(font.name.length > 0, `${font.id} missing name`);
assert.ok(font.family.length > 0, `${font.id} missing family`);
}
});
it('font ids are unique', () => {
const seen = new Set<string>();
for (const font of TERMINAL_FONTS) {
assert.equal(seen.has(font.id), false, `duplicate id: ${font.id}`);
seen.add(font.id);
}
});
});

View File

@@ -1,6 +1,10 @@
/**
* Terminal Fonts Configuration
* Includes programming and shell-friendly monospace fonts
*
* `family` is the raw CSS font-family string for the Latin glyphs only.
* CJK and icon fallbacks are composed at runtime by composeFontFamilyStack()
* in cjkFonts.ts, which lets users pick the CJK font independently or have
* one chosen automatically per Latin font.
*/
export interface TerminalFont {
@@ -11,274 +15,92 @@ export interface TerminalFont {
category: 'monospace' | 'proportional';
}
// Fonts that hint the browser to pick a CJK-capable fallback when the primary
// monospace font lacks Chinese glyphs. Kept ASCII-only and ordered so that the
// generic monospace fallback remains earlier in the stack (important for cell
// width stability in xterm.js).
const CJK_FALLBACK_FONTS = [
'"Sarasa Mono SC"',
'"Noto Sans Mono CJK SC"',
'"Noto Sans Mono CJK"',
'"Source Han Mono SC"',
'"WenQuanYi Zen Hei Mono"',
'"PingFang SC"',
'"Hiragino Sans GB"',
'"Microsoft YaHei UI"',
'"Microsoft YaHei"',
'"SimSun"',
];
// Nerd Font symbol-only fallback. Appended after CJK fallbacks so the browser
// can locate Private Use Area glyphs (powerline / devicons / etc.) when the
// primary font does not ship them — without forcing the user to pick a Nerd
// Font variant manually. Mono variants come first to preserve cell width.
const NERD_FONT_FALLBACK_FONTS = [
'"Symbols Nerd Font Mono"',
'"Symbols Nerd Font"',
];
const CJK_FALLBACK_STACK = CJK_FALLBACK_FONTS.join(', ');
const NERD_FONT_FALLBACK_STACK = NERD_FONT_FALLBACK_FONTS.join(', ');
export const withCjkFallback = (family: string) => {
const trimmed = family.trim();
const segments: string[] = [trimmed];
if (
CJK_FALLBACK_STACK &&
!CJK_FALLBACK_FONTS.some((f) => trimmed.includes(f.replace(/"/g, '')))
) {
segments.push(CJK_FALLBACK_STACK);
}
if (
NERD_FONT_FALLBACK_STACK &&
!NERD_FONT_FALLBACK_FONTS.some((f) => trimmed.includes(f.replace(/"/g, '')))
) {
segments.push(NERD_FONT_FALLBACK_STACK);
}
return segments.join(', ');
};
const BASE_TERMINAL_FONTS: TerminalFont[] = [
{
id: 'menlo',
name: 'Menlo',
family: 'Menlo, monospace',
description: 'macOS system font, clean and professional',
category: 'monospace',
},
{
id: 'monaco',
name: 'Monaco',
family: 'Monaco, monospace',
description: 'Classic monospace, excellent readability',
category: 'monospace',
},
{
id: 'consolas',
name: 'Consolas',
family: 'Consolas, monospace',
description: 'Windows-style monospace, clear and compact',
category: 'monospace',
},
{
id: 'courier-new',
name: 'Courier New',
family: '"Courier New", monospace',
description: 'Classic typewriter style, universal support',
category: 'monospace',
},
{
id: 'source-code-pro',
name: 'Source Code Pro',
family: '"Source Code Pro", monospace',
description: 'Adobe\'s professional programming font',
category: 'monospace',
},
{
id: 'fira-code',
name: 'Fira Code',
family: '"Fira Code", monospace',
description: 'Monospace font with programming ligatures',
category: 'monospace',
},
{
id: 'fira-mono',
name: 'Fira Mono',
family: '"Fira Mono", monospace',
description: 'Clean monospace without ligatures',
category: 'monospace',
},
{
id: 'inconsolata',
name: 'Inconsolata',
family: 'Inconsolata, monospace',
description: 'Elegant and readable monospace font',
category: 'monospace',
},
{
id: 'dejavu-sans-mono',
name: 'DejaVu Sans Mono',
family: '"DejaVu Sans Mono", monospace',
description: 'Wide character support, very readable',
category: 'monospace',
},
{
id: 'liberation-mono',
name: 'Liberation Mono',
family: '"Liberation Mono", monospace',
description: 'Open source monospace font, Courier alternative',
category: 'monospace',
},
{
id: 'jetbrains-mono',
name: 'JetBrains Mono',
family: '"JetBrains Mono", monospace',
description: 'Professional font designed for IDEs',
category: 'monospace',
},
{
id: 'victor-mono',
name: 'Victor Mono',
family: '"Victor Mono", monospace',
description: 'Stylish monospace with italic support',
category: 'monospace',
},
{
id: 'cascadia-code',
name: 'Cascadia Code',
family: '"Cascadia Code", monospace',
description: 'Microsoft\'s modern monospace font',
category: 'monospace',
},
{
id: 'cascadia-mono',
name: 'Cascadia Mono',
family: '"Cascadia Mono", monospace',
description: 'Cascadia without ligatures',
category: 'monospace',
},
{
id: 'droid-sans-mono',
name: 'Droid Sans Mono',
family: '"Droid Sans Mono", monospace',
description: 'Google\'s Droid monospace font',
category: 'monospace',
},
{
id: 'ubuntu-mono',
name: 'Ubuntu Mono',
family: '"Ubuntu Mono", monospace',
description: 'Ubuntu\'s official monospace font',
category: 'monospace',
},
{
id: 'roboto-mono',
name: 'Roboto Mono',
family: '"Roboto Mono", monospace',
description: 'Google\'s Roboto monospace variant',
category: 'monospace',
},
{
id: 'ibm-plex-mono',
name: 'IBM Plex Mono',
family: '"IBM Plex Mono", monospace',
description: 'IBM\'s professional monospace font',
category: 'monospace',
},
{
id: 'space-mono',
name: 'Space Mono',
family: '"Space Mono", monospace',
description: 'Geometric monospace with strong personality',
category: 'monospace',
},
{
id: 'input-mono',
name: 'Input Mono',
family: '"Input Mono", monospace',
description: 'Designed specifically for coding',
category: 'monospace',
},
{
id: 'hack',
name: 'Hack',
family: 'Hack, monospace',
description: 'Designed for source code, excellent in terminals',
category: 'monospace',
},
{
id: 'anonymous-pro',
name: 'Anonymous Pro',
family: '"Anonymous Pro", monospace',
description: 'Designed for coding and terminal use',
category: 'monospace',
},
{
id: 'programmer-fonts',
name: 'Programmer Fonts',
family: '"Programmer Fonts", monospace',
description: 'Optimized for programming with clear glyphs',
category: 'monospace',
},
{
id: 'pt-mono',
name: 'PT Mono',
family: '"PT Mono", monospace',
description: 'ParaType\'s monospace font',
category: 'monospace',
},
{
id: 'iosevka',
name: 'Iosevka',
family: 'Iosevka, monospace',
description: 'Highly customizable monospace font',
category: 'monospace',
},
{
id: 'ioskeley-mono',
name: 'Ioskeley Mono',
family: '"Ioskeley Mono", monospace',
description: 'Iosevka variant mimicking Berkeley Mono style',
category: 'monospace',
},
{
id: 'mononoki',
name: 'Mononoki',
family: 'Mononoki, monospace',
description: 'Crisp and clear monospace with ligatures',
category: 'monospace',
},
{
id: 'go-mono',
name: 'Go Mono',
family: '"Go Mono", monospace',
description: 'Google Go\'s monospace font',
category: 'monospace',
},
{
id: 'overpass-mono',
name: 'Overpass Mono',
family: '"Overpass Mono", monospace',
description: 'Open source monospace with good coverage',
category: 'monospace',
},
{
id: 'comic-sans-ms',
name: 'Comic Sans MS',
family: '"Comic Sans MS", monospace',
description: 'Casual, non-traditional terminal font',
category: 'monospace',
},
// Existing Latin monospace fonts (ids unchanged for sync compatibility)
{ id: 'menlo', name: 'Menlo', family: 'Menlo, monospace', description: 'macOS system font, clean and professional', category: 'monospace' },
{ id: 'monaco', name: 'Monaco', family: 'Monaco, monospace', description: 'Classic monospace, excellent readability', category: 'monospace' },
{ id: 'consolas', name: 'Consolas', family: 'Consolas, monospace', description: 'Windows-style monospace, clear and compact', category: 'monospace' },
{ id: 'courier-new', name: 'Courier New', family: '"Courier New", monospace', description: 'Classic typewriter style, universal support', category: 'monospace' },
{ id: 'source-code-pro', name: 'Source Code Pro', family: '"Source Code Pro", monospace', description: "Adobe's professional programming font", category: 'monospace' },
{ id: 'fira-code', name: 'Fira Code', family: '"Fira Code", monospace', description: 'Monospace font with programming ligatures', category: 'monospace' },
{ id: 'fira-mono', name: 'Fira Mono', family: '"Fira Mono", monospace', description: 'Clean monospace without ligatures', category: 'monospace' },
{ id: 'inconsolata', name: 'Inconsolata', family: 'Inconsolata, monospace', description: 'Elegant and readable monospace font', category: 'monospace' },
{ id: 'dejavu-sans-mono', name: 'DejaVu Sans Mono', family: '"DejaVu Sans Mono", monospace', description: 'Wide character support, very readable', category: 'monospace' },
{ id: 'liberation-mono', name: 'Liberation Mono', family: '"Liberation Mono", monospace', description: 'Open source monospace font, Courier alternative', category: 'monospace' },
{ id: 'jetbrains-mono', name: 'JetBrains Mono', family: '"JetBrains Mono", monospace', description: 'Professional font designed for IDEs', category: 'monospace' },
{ id: 'victor-mono', name: 'Victor Mono', family: '"Victor Mono", monospace', description: 'Stylish monospace with italic support', category: 'monospace' },
{ id: 'cascadia-code', name: 'Cascadia Code', family: '"Cascadia Code", monospace', description: "Microsoft's modern monospace font", category: 'monospace' },
{ id: 'cascadia-mono', name: 'Cascadia Mono', family: '"Cascadia Mono", monospace', description: 'Cascadia without ligatures', category: 'monospace' },
{ id: 'droid-sans-mono', name: 'Droid Sans Mono', family: '"Droid Sans Mono", monospace', description: "Google's Droid monospace font", category: 'monospace' },
{ id: 'ubuntu-mono', name: 'Ubuntu Mono', family: '"Ubuntu Mono", monospace', description: "Ubuntu's official monospace font", category: 'monospace' },
{ id: 'roboto-mono', name: 'Roboto Mono', family: '"Roboto Mono", monospace', description: "Google's Roboto monospace variant", category: 'monospace' },
{ id: 'ibm-plex-mono', name: 'IBM Plex Mono', family: '"IBM Plex Mono", monospace', description: "IBM's professional monospace font", category: 'monospace' },
{ id: 'space-mono', name: 'Space Mono', family: '"Space Mono", monospace', description: 'Geometric monospace with strong personality', category: 'monospace' },
{ id: 'input-mono', name: 'Input Mono', family: '"Input Mono", monospace', description: 'Designed specifically for coding', category: 'monospace' },
{ id: 'hack', name: 'Hack', family: 'Hack, monospace', description: 'Designed for source code, excellent in terminals', category: 'monospace' },
{ id: 'anonymous-pro', name: 'Anonymous Pro', family: '"Anonymous Pro", monospace', description: 'Designed for coding and terminal use', category: 'monospace' },
{ id: 'programmer-fonts', name: 'Programmer Fonts', family: '"Programmer Fonts", monospace', description: 'Optimized for programming with clear glyphs', category: 'monospace' },
{ id: 'pt-mono', name: 'PT Mono', family: '"PT Mono", monospace', description: "ParaType's monospace font", category: 'monospace' },
{ id: 'iosevka', name: 'Iosevka', family: 'Iosevka, monospace', description: 'Highly customizable monospace font', category: 'monospace' },
{ id: 'ioskeley-mono', name: 'Ioskeley Mono', family: '"Ioskeley Mono", monospace', description: 'Iosevka variant mimicking Berkeley Mono style', category: 'monospace' },
{ id: 'mononoki', name: 'Mononoki', family: 'Mononoki, monospace', description: 'Crisp and clear monospace with ligatures', category: 'monospace' },
{ id: 'go-mono', name: 'Go Mono', family: '"Go Mono", monospace', description: "Google Go's monospace font", category: 'monospace' },
{ id: 'overpass-mono', name: 'Overpass Mono', family: '"Overpass Mono", monospace', description: 'Open source monospace with good coverage', category: 'monospace' },
// True monospace CJK-coverage fonts only. PingFang SC and Microsoft
// YaHei UI (the OS system fonts) are deliberately omitted — they are
// proportional sans-serif designs whose Latin glyphs render with
// variable widths and whose CJK glyphs don't fit a terminal's 2x cell
// grid. Picking one as the primary font produced visibly bloated
// spacing for ASCII characters in #931.
{ id: 'sarasa-mono-sc', name: 'Sarasa Mono SC', family: '"Sarasa Mono SC", monospace', description: 'Iosevka + Source Han Sans (Simplified Chinese), 2:1 monospace', category: 'monospace' },
{ id: 'sarasa-mono-tc', name: 'Sarasa Mono TC', family: '"Sarasa Mono TC", monospace', description: 'Iosevka + Source Han Sans (Traditional Chinese), 2:1 monospace', category: 'monospace' },
{ id: 'maple-mono-cn', name: 'Maple Mono CN', family: '"Maple Mono CN", monospace', description: 'Maple Mono with unified Latin + Simplified Chinese metrics', category: 'monospace' },
{ id: 'lxgw-wenkai-mono', name: 'LXGW WenKai Mono', family: '"LXGW WenKai Mono", monospace', description: 'Monospace Kaishu (regular-script) derived from Fontworks Klee One', category: 'monospace' },
];
export const TERMINAL_FONTS: TerminalFont[] = BASE_TERMINAL_FONTS.map((font) => ({
...font,
family: withCjkFallback(font.family),
}));
export const TERMINAL_FONTS: TerminalFont[] = BASE_TERMINAL_FONTS;
export const DEFAULT_FONT_SIZE = 14;
export const MIN_FONT_SIZE = 10;
export const MAX_FONT_SIZE = 32;
// Font ids that earlier versions of netcatty exposed in the primary font
// dropdown but that are proportional (non-monospace) and produce broken
// cell-grid alignment when used as a terminal font. Reads should migrate
// these to a sane default.
const DEPRECATED_PRIMARY_FONT_IDS = new Set<string>([
'pingfang-sc',
'microsoft-yahei',
'comic-sans-ms',
]);
export function isDeprecatedPrimaryFontId(fontId: string | null | undefined): boolean {
return !!fontId && DEPRECATED_PRIMARY_FONT_IDS.has(fontId);
}
/**
* In-place migration for any object carrying `fontFamily` /
* `fontFamilyOverride` (Host, GroupConfig). When the saved id is one
* we've since removed from TERMINAL_FONTS, drop the override so the
* record inherits the global default rather than silently rendering
* "fallback to fonts[0]" while still claiming an override is active.
*
* Returns the (possibly new) value to assign back. Caller decides
* whether to mutate or copy; both are safe with this shape.
*/
export function migrateDeprecatedFontOverride<
T extends { fontFamily?: string; fontFamilyOverride?: boolean },
>(record: T): T {
if (!isDeprecatedPrimaryFontId(record.fontFamily)) return record;
const next = { ...record };
delete next.fontFamily;
if (next.fontFamilyOverride === true) {
next.fontFamilyOverride = false;
}
return next;
}
export function getRawFontFamily(fontId: string): string {
return (TERMINAL_FONTS.find((f) => f.id === fontId) || TERMINAL_FONTS[0]).family;
}

View File

@@ -4,9 +4,14 @@
* for establishing and managing SSH port forwarding tunnels.
*/
import { Host, Identity, PortForwardingRule, SSHKey } from '../../domain/models';
import { Host, Identity, PortForwardingRule, SSHKey, TerminalSettings } from '../../domain/models';
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { resolveBridgeKeyAuth, resolveHostAuth } from '../../domain/sshAuth';
import { resolveHostKeepalive } from '../../domain/host';
// Fallback matching DEFAULT_TERMINAL_SETTINGS so older call sites that don't
// thread terminalSettings still get the cloud-friendly defaults.
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
import { logger } from '../../lib/logger';
import { localStorageAdapter } from '../persistence/localStorageAdapter';
import { STORAGE_KEY_PF_RECONNECT_CANCEL } from '../config/storageKeys';
@@ -361,8 +366,10 @@ export const startPortForward = async (
keys: SSHKey[],
identities: Identity[],
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
enableReconnect = false
enableReconnect = false,
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>,
): Promise<{ success: boolean; error?: string }> => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
const bridge = netcattyBridge.get();
// Clear any existing reconnect timer
@@ -440,6 +447,7 @@ export const startPortForward = async (
) {
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -462,6 +470,8 @@ export const startPortForward = async (
}
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
}
@@ -542,6 +552,8 @@ export const startPortForward = async (
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
identityFilePaths: keyAuth.identityFilePaths,
legacyAlgorithms: host.legacyAlgorithms,
keepaliveInterval: resolveHostKeepalive(host, globalKeepalive).interval,
keepaliveCountMax: resolveHostKeepalive(host, globalKeepalive).countMax,
});
if (!result.success) {

View File

@@ -0,0 +1,203 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
extractPrimaryFamily,
detectInstalledWithContext,
isFontInstalled,
setSystemFamilies,
hasAuthoritativeData,
clearFontAvailabilityCache,
subscribeFontAvailability,
getFontAvailabilityVersion,
} from './fontAvailability';
describe('extractPrimaryFamily', () => {
it('strips quotes from a quoted name', () => {
assert.equal(extractPrimaryFamily('"Fira Code", monospace'), 'Fira Code');
});
it('returns unquoted single-word names as-is', () => {
assert.equal(extractPrimaryFamily('Menlo, monospace'), 'Menlo');
});
it('returns the first family in a list', () => {
assert.equal(
extractPrimaryFamily('"Source Code Pro", "Fira Code", monospace'),
'Source Code Pro',
);
});
it('handles a single name without comma', () => {
assert.equal(extractPrimaryFamily('Iosevka'), 'Iosevka');
});
});
function makeContextWithInstalledFamilies(installed: Set<string>) {
// Mock canvas measurement: each generic fallback has a stable width;
// a "real" installed font produces a different width per fallback.
// Collision-resistant: position-weighted polynomial hash.
const widthFor = (family: string): number => {
let h = 0;
for (let i = 0; i < family.length; i++) {
h = (h * 31 + family.charCodeAt(i)) >>> 0;
}
return 100 + (h % 9973);
};
return {
measureText: (font: string, _text: string) => {
const match = font.match(/^\d+px\s+(.+)$/);
if (!match) return 0;
const familyList = match[1];
const families = familyList
.split(',')
.map((f) => f.trim().replace(/^["']|["']$/g, ''));
for (const f of families) {
if (installed.has(f) || ['serif', 'sans-serif', 'monospace'].includes(f)) {
return widthFor(f);
}
}
return 0;
},
};
}
describe('detectInstalledWithContext (canvas fallback)', () => {
it('detects an installed font (width differs from all 3 generic fallbacks)', () => {
const ctx = makeContextWithInstalledFamilies(new Set(['Fira Code']));
assert.equal(detectInstalledWithContext('Fira Code', ctx), true);
});
it('rejects a non-installed font (falls through to fallback)', () => {
const ctx = makeContextWithInstalledFamilies(new Set(['Fira Code']));
assert.equal(detectInstalledWithContext('Definitely Not A Font', ctx), false);
});
it('treats KNOWN_BUNDLED_FAMILIES as installed regardless of canvas evidence', () => {
const ctx = makeContextWithInstalledFamilies(new Set());
assert.equal(detectInstalledWithContext('JetBrains Mono', ctx), true);
assert.equal(detectInstalledWithContext('Sarasa Mono SC', ctx), true);
});
it('treats a font as installed when it matches one generic but differs from the others', () => {
// Regression guard for codex P2 review on PR #940: on macOS the
// `monospace` generic resolves to Menlo, so measure(`"Menlo", monospace`)
// equals measure(`monospace`). The detector must NOT report Menlo
// as uninstalled just because of that single collision — it should
// recognize installation via the other two generic baselines.
const ctx = {
measureText: (font: string): number => {
// "Menlo", X → Menlo's metrics (always 100, regardless of fallback)
if (font.includes('"Menlo"')) return 100;
// Generic baselines
if (font === '72px serif') return 50;
if (font === '72px sans-serif') return 80;
if (font === '72px monospace') return 100; // identical to Menlo
// Unknown family followed by a generic → falls to that generic
const tail = font.split(',').pop()?.trim() ?? '';
if (tail === 'serif') return 50;
if (tail === 'sans-serif') return 80;
if (tail === 'monospace') return 100;
return 0;
},
};
assert.equal(detectInstalledWithContext('Menlo', ctx), true);
});
it('still reports a clearly-uninstalled font as missing even with the looser rule', () => {
// "Some" semantics must not introduce false positives for fonts
// that genuinely aren't installed — those fall through to each
// generic and match all three baselines.
const ctx = makeContextWithInstalledFamilies(new Set(['Menlo']));
assert.equal(detectInstalledWithContext('Definitely Not Installed', ctx), false);
});
});
describe('isFontInstalled with authoritative system data', () => {
beforeEach(() => {
clearFontAvailabilityCache();
});
it('returns true for bundled families even without authoritative data', () => {
assert.equal(hasAuthoritativeData(), false);
assert.equal(isFontInstalled('JetBrains Mono'), true);
assert.equal(isFontInstalled('Sarasa Mono SC'), true);
});
it('answers from authoritative set once setSystemFamilies has run', () => {
setSystemFamilies(new Set(['menlo', 'fira code']));
assert.equal(hasAuthoritativeData(), true);
assert.equal(isFontInstalled('Menlo'), true);
assert.equal(isFontInstalled('Fira Code'), true);
assert.equal(isFontInstalled('Sarasa Mono SC'), true, 'bundled wins over set');
assert.equal(isFontInstalled('PingFang SC'), false, 'not in authoritative set');
assert.equal(isFontInstalled('Programmer Fonts'), false, 'fictitious name');
});
it('lookup is case-insensitive (set stores lowercase)', () => {
setSystemFamilies(new Set(['microsoft yahei ui']));
assert.equal(isFontInstalled('Microsoft YaHei UI'), true);
assert.equal(isFontInstalled('MICROSOFT YAHEI UI'), true);
});
it('falls back to safe-default (true) without DOM and without authoritative data', () => {
assert.equal(hasAuthoritativeData(), false);
assert.equal(isFontInstalled('Some Unknown Font'), true);
});
it('a null authoritative set means we re-enter fallback mode', () => {
setSystemFamilies(new Set(['menlo']));
assert.equal(hasAuthoritativeData(), true);
setSystemFamilies(null);
assert.equal(hasAuthoritativeData(), false);
});
});
describe('font availability subscription', () => {
beforeEach(() => {
clearFontAvailabilityCache();
});
it('notifies subscribers when setSystemFamilies is called', () => {
// Regression guard for codex P2 review on PR #940:
// TerminalCjkFontSelect memoizes visibleOptions on [value] but the
// filter calls isFontInstalled which depends on systemFamilies.
// Subscribers wired via useSyncExternalStore must fire so memos
// recompute when authoritative data arrives.
let calls = 0;
const unsubscribe = subscribeFontAvailability(() => {
calls += 1;
});
setSystemFamilies(new Set(['menlo']));
assert.equal(calls, 1);
setSystemFamilies(new Set(['menlo', 'fira code']));
assert.equal(calls, 2);
setSystemFamilies(null);
assert.equal(calls, 3);
unsubscribe();
setSystemFamilies(new Set(['menlo']));
assert.equal(calls, 3, 'unsubscribe stops notifications');
});
it('version monotonically increases on each setSystemFamilies call', () => {
const v0 = getFontAvailabilityVersion();
setSystemFamilies(new Set(['menlo']));
const v1 = getFontAvailabilityVersion();
setSystemFamilies(new Set(['menlo', 'fira code']));
const v2 = getFontAvailabilityVersion();
assert.ok(v1 > v0, 'first call bumps version');
assert.ok(v2 > v1, 'second call bumps version');
});
it('clearFontAvailabilityCache also notifies subscribers', () => {
let calls = 0;
subscribeFontAvailability(() => {
calls += 1;
});
setSystemFamilies(new Set(['menlo']));
const after = calls;
clearFontAvailabilityCache();
assert.ok(calls > after, 'clear notifies too');
});
});

162
lib/fontAvailability.ts Normal file
View File

@@ -0,0 +1,162 @@
/**
* Decides whether a CSS font-family is actually rendered (system-installed
* or loaded via @font-face) on the current machine. Used to filter the
* terminal font dropdowns.
*
* Why not document.fonts.check(): in Chromium it returns true for any
* syntactically-valid family name regardless of whether that font is
* actually installed (a deliberate fingerprinting-mitigation choice), so
* it produces massive false positives. We rely instead on:
*
* 1. KNOWN_BUNDLED_FAMILIES — fonts we ship via @font-face / @fontsource.
* Always true.
* 2. setSystemFamilies() — an authoritative Set populated by fontStore
* after Local Font Access API returns. Membership lookup. When
* populated, this is the only signal needed for system fonts.
* 3. Canvas width fallback — used only before setSystemFamilies() runs
* or when the Font Access API is unavailable / denied. A font counts
* as installed only when its rendered width differs from ALL three
* generic fallbacks (serif, sans-serif, monospace).
*/
import { splitFontFamilyList } from '../infrastructure/config/cjkFonts';
const KNOWN_BUNDLED_FAMILIES = new Set<string>([
'JetBrains Mono', // @fontsource/jetbrains-mono (regular, 500, 600)
'Sarasa Mono SC', // public/fonts/SarasaMonoSC-Regular.woff2 (OFL)
]);
let systemFamilies: Set<string> | null = null;
let availabilityVersion = 0;
const listeners = new Set<() => void>();
/**
* "Fira Code", monospace → Fira Code | Menlo, monospace → Menlo.
* Quote-aware so a single family name containing commas (CSS permits
* `"Foo, Inc. Mono"`) survives intact instead of being truncated.
*/
export function extractPrimaryFamily(familyCssString: string): string {
const first = splitFontFamilyList(familyCssString)[0] ?? '';
return first.replace(/^["']|["']$/g, '');
}
/**
* Called by fontStore once Local Font Access API has returned the full
* list of installed family names (lower-cased). After this runs,
* isFontInstalled answers from this authoritative set rather than from
* canvas measurement.
*
* Notifies subscribers so React components memoizing on availability
* can recompute (e.g. dropdown filters that called isFontInstalled
* before authoritative data arrived).
*/
export function setSystemFamilies(families: Set<string> | null): void {
systemFamilies = families;
availabilityVersion += 1;
for (const listener of listeners) listener();
}
/** True when authoritative system data is available; canvas fallback skipped. */
export function hasAuthoritativeData(): boolean {
return systemFamilies !== null;
}
/**
* Subscribe to changes in font availability. Returns an unsubscribe fn.
* Used together with getFontAvailabilityVersion() and
* useSyncExternalStore in React components that filter on
* isFontInstalled() — so their useMemo dependencies invalidate when
* the authoritative install set is populated or cleared.
*/
export function subscribeFontAvailability(callback: () => void): () => void {
listeners.add(callback);
return () => {
listeners.delete(callback);
};
}
/** Monotonically increasing version, bumped on every setSystemFamilies. */
export function getFontAvailabilityVersion(): number {
return availabilityVersion;
}
const cache = new Map<string, boolean>();
interface DetectionContext {
measureText: (font: string, text: string) => number;
}
const TEST_STRING = 'mmmmmmmmmmlli';
const FALLBACK_FAMILIES = ['serif', 'sans-serif', 'monospace'] as const;
function buildBrowserContext(): DetectionContext | null {
if (typeof document === 'undefined') return null;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
return {
measureText: (font, text) => {
ctx.font = font;
return ctx.measureText(text).width;
},
};
}
/**
* Pure detection logic — exported for testing without a DOM.
*
* Returns true if rendering the probe string against ANY of the three
* generic fallbacks (serif, sans-serif, monospace) with the target font
* listed first produces a different width than the bare generic. We use
* "some" rather than "every" because some platform defaults make a
* generic family literally identical to a real installed font — for
* example on macOS the `monospace` generic resolves to Menlo, so
* measure("'Menlo', monospace") === measure("monospace"). Requiring all
* three to differ would then falsely report Menlo as missing. A truly
* uninstalled font falls through to each generic in turn and matches
* all three, so "some" still correctly returns false for those.
*/
export function detectInstalledWithContext(
family: string,
ctx: DetectionContext,
): boolean {
if (KNOWN_BUNDLED_FAMILIES.has(family)) return true;
return FALLBACK_FAMILIES.some((fb) => {
const baseWidth = ctx.measureText(`72px ${fb}`, TEST_STRING);
const targetWidth = ctx.measureText(`72px "${family}", ${fb}`, TEST_STRING);
return baseWidth !== targetWidth;
});
}
export function isFontInstalled(family: string): boolean {
if (KNOWN_BUNDLED_FAMILIES.has(family)) return true;
// Authoritative path: Local Font Access API enumeration.
if (systemFamilies) {
return systemFamilies.has(family.toLowerCase());
}
// Fallback path: canvas measurement, cached per family. Only used
// before setSystemFamilies has run, or when the API is denied.
const cached = cache.get(family);
if (cached !== undefined) return cached;
const ctx = buildBrowserContext();
// No DOM (SSR / tests) and no authoritative data → treat as available
// so we don't aggressively hide everything.
if (!ctx) {
cache.set(family, true);
return true;
}
const result = detectInstalledWithContext(family, ctx);
cache.set(family, result);
return result;
}
export function clearFontAvailabilityCache(): void {
cache.clear();
systemFamilies = null;
availabilityVersion += 1;
for (const listener of listeners) listener();
}

105
lib/localFonts.test.ts Normal file
View File

@@ -0,0 +1,105 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
getAllSystemFontFamilies,
getMonospaceFonts,
__resetLocalFontsCacheForTesting,
} from './localFonts';
interface MockWindow {
queryLocalFonts: () => Promise<Array<{ family: string }>>;
}
function installMockWindow(impl: MockWindow['queryLocalFonts']): void {
(globalThis as unknown as { window: MockWindow }).window = {
queryLocalFonts: impl,
};
}
function uninstallMockWindow(): void {
delete (globalThis as unknown as { window?: MockWindow }).window;
}
describe('queryLocalFonts deduplication', () => {
beforeEach(() => {
__resetLocalFontsCacheForTesting();
});
afterEach(() => {
uninstallMockWindow();
__resetLocalFontsCacheForTesting();
});
it('coalesces concurrent calls into a single Local Font Access API invocation', async () => {
// Regression guard for codex P2 review on PR #940: fontStore.initialize
// calls getMonospaceFonts() and getAllSystemFontFamilies() in
// Promise.all; both must share one underlying queryLocalFonts() call,
// not race and fire two prompts / two requests.
let callCount = 0;
installMockWindow(async () => {
callCount++;
// Tiny tick so the two callers truly overlap in time.
await new Promise<void>((r) => setTimeout(r, 5));
return [
{ family: 'Menlo' },
{ family: 'Fira Code' },
{ family: 'PingFang SC' },
];
});
const [monoFonts, allFamilies] = await Promise.all([
getMonospaceFonts(),
getAllSystemFontFamilies(),
]);
assert.equal(callCount, 1, 'queryLocalFonts must be invoked exactly once');
assert.ok(allFamilies !== null);
assert.equal(allFamilies?.has('menlo'), true);
assert.equal(allFamilies?.has('pingfang sc'), true);
// Mono filter keeps only the monospace-named family.
assert.equal(
monoFonts.some((f) => f.name === 'Fira Code'),
true,
);
});
it('a second sequential call also reuses the resolved promise (no second API call)', async () => {
let callCount = 0;
installMockWindow(async () => {
callCount++;
return [{ family: 'Menlo' }];
});
await getAllSystemFontFamilies();
await getAllSystemFontFamilies();
await getMonospaceFonts();
assert.equal(callCount, 1);
});
it('returns null authoritative set when Local Font Access API is unavailable', async () => {
// No window installed → API path skipped.
const result = await getAllSystemFontFamilies();
assert.equal(result, null);
});
it('retries on the next call after a transient failure (does not sticky-cache empty result)', async () => {
// Regression guard for codex P2 review on PR #940: queryLocalFonts
// failure should NOT poison the cache for the rest of the session.
let callCount = 0;
installMockWindow(async () => {
callCount++;
if (callCount === 1) {
throw new Error('transient failure (e.g. LFA permission not ready)');
}
return [{ family: 'Menlo' }, { family: 'Fira Code' }];
});
const first = await getAllSystemFontFamilies();
assert.equal(first, null, 'first failure returns null authoritative set');
// Same module, second invocation: must retry queryLocalFonts.
const second = await getAllSystemFontFamilies();
assert.equal(callCount, 2, 'queryLocalFonts retried on next call');
assert.equal(second?.has('menlo'), true, 'second call sees the fonts');
});
});

View File

@@ -1,4 +1,4 @@
import { TerminalFont, withCjkFallback } from "../infrastructure/config/fonts"
import { TerminalFont } from "../infrastructure/config/fonts"
/**
* Type definition for Local Font Access API
@@ -89,43 +89,101 @@ function isMonospaceFont(familyName: string): boolean {
});
}
// Cached unfiltered system family list so we don't hit the Local Font
// Access API more than once per session. Populated as a side effect of
// queryAllSystemFontsOnce(), which both getMonospaceFonts() and
// fontAvailability.ts read.
let allSystemFamiliesCache: Set<string> | null = null;
// In-flight promise dedup: when fontStore.initialize() runs
// getMonospaceFonts() and getAllSystemFontFamilies() in parallel, both
// would otherwise hit queryLocalFonts() before the cache is populated,
// causing two redundant Local Font Access API calls and potential
// permission-handling races. Caching the promise itself means
// concurrent callers await the same single invocation.
let queryPromise: Promise<LocalFontData[]> | null = null;
/** Test-only: clears in-flight promise and cached set so each test gets a fresh module state. */
export function __resetLocalFontsCacheForTesting(): void {
queryPromise = null;
allSystemFamiliesCache = null;
}
function queryAllSystemFontsOnce(): Promise<LocalFontData[]> {
if (queryPromise) return queryPromise;
queryPromise = (async () => {
if (typeof window === "undefined" || !("queryLocalFonts" in window)) {
return [];
}
try {
const queryLocalFonts = (window as unknown as {
queryLocalFonts: () => Promise<LocalFontData[]>;
}).queryLocalFonts;
const fonts = await queryLocalFonts();
allSystemFamiliesCache = new Set(
fonts.map((f) => f.family.toLowerCase()),
);
return fonts;
} catch (error) {
// Don't sticky-cache a transient failure (e.g. LFA permission
// not ready yet at app boot, AbortError, etc.). Clearing the
// module-level promise lets the very next caller retry the
// API. Successful calls keep their cached promise as before,
// so this only retries when something actually went wrong.
console.warn('Failed to query local fonts:', error);
queryPromise = null;
return [];
}
})();
return queryPromise;
}
/**
* Returns the case-insensitive set of every font family installed on the
* system, as reported by the Local Font Access API. Used by
* fontAvailability.ts to decide which built-in font choices to show in
* the dropdown.
*
* Returns null when the API is unavailable or permission has been
* denied — callers should treat that as "no authoritative data" and
* fall back to canvas-width detection.
*/
export async function getAllSystemFontFamilies(): Promise<Set<string> | null> {
if (allSystemFamiliesCache) return allSystemFamiliesCache;
await queryAllSystemFontsOnce();
return allSystemFamiliesCache;
}
/**
* Queries local monospace fonts from the system using the Font Access API.
* Returns an empty array if the API is not available or permission is denied.
*/
export async function getMonospaceFonts(): Promise<TerminalFont[]> {
// Check if the Font Access API is available
if (typeof window === "undefined" || !("queryLocalFonts" in window)) {
return [];
}
const fonts = await queryAllSystemFontsOnce();
if (fonts.length === 0) return [];
try {
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
const fonts = await queryLocalFonts();
// Filter monospace fonts using robust word boundary matching
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
// Filter monospace fonts using robust word boundary matching
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
// Deduplicate by family name, case-insensitive (API may return multiple entries per family)
const uniqueFamilies = new Set<string>();
const dedupedFonts = monoFonts.filter(f => {
const key = f.family.toLowerCase();
if (uniqueFamilies.has(key)) return false;
uniqueFamilies.add(key);
return true;
});
// Deduplicate by family name, case-insensitive (API may return multiple entries per family)
const uniqueFamilies = new Set<string>();
const dedupedFonts = monoFonts.filter(f => {
const key = f.family.toLowerCase();
if (uniqueFamilies.has(key)) return false;
uniqueFamilies.add(key);
return true;
});
// Map to TerminalFont structure with CJK fallback applied
return dedupedFonts.map(f => ({
// Raw Latin family only; CJK fallback is composed at runtime by
// composeFontFamilyStack() in cjkFonts.ts.
return dedupedFonts.map(f => {
const quoted = /\s/.test(f.family) ? `"${f.family}"` : f.family;
return {
id: f.family,
name: f.family,
family: withCjkFallback(f.family + ', monospace'),
family: `${quoted}, monospace`,
description: `Local font: ${f.family}`,
category: 'monospace' as const,
}));
} catch (error) {
// Handle permission denied or other errors gracefully
console.warn('Failed to query local fonts:', error);
return [];
}
};
});
}

View File

@@ -33,7 +33,7 @@
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts lib/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",

View File

@@ -0,0 +1,113 @@
Copyright (c) 2015-2025, Renzhi Li (aka. Belleve Invis, belleve@typeof.net).
Portions Copyright (c) 2016 The Inter Project Authors.
Portions Copyright (c) 2014-2021 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'.
Portions Copyright (c) 2012 Google Inc.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
--------------------------
SIL Open Font License v1.1
====================================================
Preamble
----------
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
Definitions
-------------
`"Font Software"` refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
`"Reserved Font Name"` refers to any names specified as such after the
copyright statement(s).
`"Original Version"` refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
`"Modified Version"` refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
`"Author"` refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
Permission & Conditions
------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5. The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
Termination
-----------
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.