Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
109d0a7ab7 | ||
|
|
92ecd84edf | ||
|
|
311f44525b | ||
|
|
b4e185e1c6 | ||
|
|
92dd898eb4 |
5
App.tsx
5
App.tsx
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -174,6 +174,7 @@ export const useSftpState = (
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
terminalSettings: options?.terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
leftTabs,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -2394,6 +2394,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={getTerminalCwd}
|
||||
onRequestTerminalFocus={refocusActiveTerminalSession}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
153
components/settings/TerminalCjkFontSelect.tsx
Normal file
153
components/settings/TerminalCjkFontSelect.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
10
global.d.ts
vendored
@@ -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 {
|
||||
|
||||
27
index.css
27
index.css
@@ -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
|
||||
============================================ */
|
||||
|
||||
249
infrastructure/config/cjkFonts.test.ts
Normal file
249
infrastructure/config/cjkFonts.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
183
infrastructure/config/cjkFonts.ts
Normal file
183
infrastructure/config/cjkFonts.ts
Normal 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(', ');
|
||||
}
|
||||
69
infrastructure/config/fonts.test.ts
Normal file
69
infrastructure/config/fonts.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
203
lib/fontAvailability.test.ts
Normal file
203
lib/fontAvailability.test.ts
Normal 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
162
lib/fontAvailability.ts
Normal 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
105
lib/localFonts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
113
public/fonts/SarasaMono-LICENSE.txt
Normal file
113
public/fonts/SarasaMono-LICENSE.txt
Normal 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.
|
||||
BIN
public/fonts/SarasaMonoSC-Regular.woff2
Normal file
BIN
public/fonts/SarasaMonoSC-Regular.woff2
Normal file
Binary file not shown.
Reference in New Issue
Block a user