Files
Netcatty/application/state/useAppLockState.ts
T
陈大猫andClaude Opus 5.5 fa554888a6 perf(terminal): stop per-second churn while connecting and while idle (#4118)
* perf(terminal): stop per-second churn while connecting and while idle

- Connecting sessions: the 200 ms progress animation and 1 s countdown were
  Terminal state threaded into the TerminalView ctx, so every connecting
  session (hidden tabs and startup restores included) re-rendered Terminal +
  TerminalView up to six times a second. Terminal now only publishes the
  timeout deadline and real progress events; the connection dialog animates
  and counts down locally (connectionTicker.ts). HostConnectionTestDialog
  keeps passing progress/timeLeft as before.
- Monitor runtime: polled every second unconditionally and woke every
  subscriber each time because takenAt always changes. It now polls at 1 s
  only while something is monitored (here or in another window), 10 s
  otherwise, and skips notifying for unchanged idle replies.
- Auto-reconnect progress log: capped at the newest 300 lines (it grew by a
  few lines every 5 s retry for as long as an outage lasted).
- App Lock: report activity at most once a second instead of an IPC per
  keystroke / wheel tick / pointer event.
- Tray/Dock: the renderer resends hosts only when the host list changed, and
  the main process rebuilds the macOS Dock menu (sort + one native item per
  host) only then, not on every session status change.

Real app, profiling build, main -> this change:
  3 SSH sessions connecting (3 s)   14 commits / 35.3 ms -> 8 / 1.7 ms
  idle (10 s)                       13 commits / 3.1 ms  -> 2 / 0.1 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(terminal): update observe-popup reconnect deps assertion for capped progress logs

CI failed because #4118 wrapped setProgressLogs in useCallback to apply
capConnectionProgressLogs and added it to scheduleAutoReconnect deps.
The observe-popup source assertion still expected the old deps list.

The attachExistingSession early-return behavior is unchanged; only the
stale dependency regex needed updating.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 01:11:25 -08:00

288 lines
10 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
normalizeAppLockSettings,
type AppLockSettings,
} from '../../domain/appLock';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
normalizeRuntimeAppLockState,
useAppLockRuntime,
type RuntimeAppLockReason,
type RuntimeAppLockState,
} from './useAppLockRuntime';
export type AppLockReason = RuntimeAppLockReason;
export type AppLockUnlockResult =
| { ok: true }
| { ok: false; error: 'empty' | 'incorrect' };
export interface AppLockSystemUnlockStatus {
supported: boolean;
available: boolean;
enabled: boolean;
platform: 'darwin' | 'win32' | 'unsupported';
label: 'Touch ID' | 'Windows Hello' | null;
reason: string | null;
}
export type AppLockSystemUnlockResult =
| { ok: true }
| { ok: false; error: 'disabled' | 'not-locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' };
export const DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS: AppLockSystemUnlockStatus = {
supported: false,
available: false,
enabled: false,
platform: 'unsupported',
label: null,
reason: null,
};
export function normalizeAppLockSystemUnlockStatus(input: unknown): AppLockSystemUnlockStatus {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
const platform = record?.platform === 'darwin' || record?.platform === 'win32'
? record.platform
: 'unsupported';
const label = record?.label === 'Touch ID' || record?.label === 'Windows Hello'
? record.label
: null;
return {
supported: record?.supported === true,
available: record?.available === true,
enabled: record?.enabled === true,
platform,
label,
reason: typeof record?.reason === 'string' && record.reason ? record.reason : null,
};
}
export function normalizeAppLockSystemUnlockResult(input: unknown): AppLockSystemUnlockResult {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
if (record?.ok === true) return { ok: true };
const error = record?.error;
if (
error === 'disabled' ||
error === 'not-locked' ||
error === 'unsupported' ||
error === 'unavailable' ||
error === 'cancelled' ||
error === 'failed'
) {
return { ok: false, error };
}
return { ok: false, error: 'failed' };
}
export function shouldLockOnStartup(settings: AppLockSettings): boolean {
const normalized = normalizeAppLockSettings(settings);
return normalized.enabled && normalized.passwordVerifier !== null;
}
export function shouldLockAfterIdle(
settings: AppLockSettings,
lastActivityAt: number,
now: number,
): boolean {
const normalized = normalizeAppLockSettings(settings);
if (!normalized.enabled || !normalized.passwordVerifier) return false;
if (normalized.timeoutMinutes <= 0) return false;
return now - lastActivityAt >= normalized.timeoutMinutes * 60_000;
}
export function getIdleLockDelayMs(
settings: AppLockSettings,
lastActivityAt: number,
now: number,
): number | null {
const normalized = normalizeAppLockSettings(settings);
if (!normalized.enabled || !normalized.passwordVerifier) return null;
if (normalized.timeoutMinutes <= 0) return null;
const timeoutMs = normalized.timeoutMinutes * 60_000;
return Math.max(0, timeoutMs - (now - lastActivityAt));
}
export async function resolveUnlockAttempt(password: string): Promise<AppLockUnlockResult> {
if (!password) return { ok: false, error: 'empty' };
try {
return await netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false, error: 'incorrect' };
} catch {
return { ok: false, error: 'incorrect' };
}
}
export function createOptimisticUnlockedRuntimeState(
input: RuntimeAppLockState,
now: number,
): RuntimeAppLockState {
const current = normalizeRuntimeAppLockState(input);
if (current.initialized && !current.locked && current.reason === null) {
return current;
}
return {
...current,
initialized: true,
locked: false,
reason: null,
// Keep the observed main-process version. Bumping here can outrank a real
// concurrent re-lock and leave the renderer unlocked after refresh (Codex P2).
version: current.version,
lastUnlockedAt: now,
lastActivityAt: now,
};
}
export const APP_LOCK_ACTIVITY_REPORT_INTERVAL_MS = 1000;
export function useAppLockState(settings: AppLockSettings) {
const normalizedSettings = useMemo(() => normalizeAppLockSettings(settings), [settings]);
const systemUnlockRefreshKey = `${normalizedSettings.enabled}:${normalizedSettings.systemUnlockEnabled}:${Boolean(normalizedSettings.passwordVerifier)}`;
const bridge = netcattyBridge.get();
const { runtimeState, refreshRuntimeState, setRuntimeState } = useAppLockRuntime(bridge);
const [systemUnlockStatus, setSystemUnlockStatus] = useState<AppLockSystemUnlockStatus>(
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
);
const normalizedRuntimeState = useMemo(
() => normalizeRuntimeAppLockState(runtimeState),
[runtimeState],
);
const effectiveRuntimeState = useMemo(() => {
if (normalizedRuntimeState.initialized) return normalizedRuntimeState;
if (shouldLockOnStartup(normalizedSettings)) {
return {
...normalizedRuntimeState,
locked: true,
reason: 'startup' as const,
};
}
return normalizedRuntimeState;
}, [normalizedRuntimeState, normalizedSettings]);
const lockNow = useCallback((reason: AppLockReason = 'manual') => {
if (!shouldLockOnStartup(normalizedSettings) || !reason) return;
void bridge?.setAppLockRuntimeLocked?.(reason);
}, [bridge, normalizedSettings]);
// Activity only resets a minutes-long idle timer; one report per second is
// enough instead of an IPC per keystroke, wheel tick and pointer event.
const lastActivityReportAtRef = useRef(0);
const recordActivity = useCallback(() => {
if (effectiveRuntimeState.locked) return;
const now = Date.now();
if (now - lastActivityReportAtRef.current < APP_LOCK_ACTIVITY_REPORT_INTERVAL_MS) return;
lastActivityReportAtRef.current = now;
void bridge?.reportAppLockActivity?.();
}, [bridge, effectiveRuntimeState.locked]);
const unlock = useCallback(async (password: string): Promise<AppLockUnlockResult> => {
const result = await resolveUnlockAttempt(password);
if (result.ok) {
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}
return result;
}, [refreshRuntimeState, setRuntimeState]);
const refreshSystemUnlockStatus = useCallback(async () => {
try {
const nextStatus = await bridge?.getAppLockSystemUnlockStatus?.();
const normalized = normalizeAppLockSystemUnlockStatus(nextStatus);
setSystemUnlockStatus(normalized);
return normalized;
} catch {
setSystemUnlockStatus(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS);
return DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS;
}
}, [bridge]);
const unlockWithSystemAuth = useCallback(async (): Promise<AppLockSystemUnlockResult> => {
const result = normalizeAppLockSystemUnlockResult(await bridge?.requestAppLockSystemUnlock?.());
if (result.ok) {
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}
await refreshSystemUnlockStatus().catch(() => {});
return result;
}, [bridge, refreshRuntimeState, refreshSystemUnlockStatus, setRuntimeState]);
const reset = useCallback(async (currentPassword: string) => {
if (typeof bridge?.requestAppLockReset !== 'function') {
throw new Error('App Lock reset bridge is unavailable');
}
const result = await bridge.requestAppLockReset(currentPassword);
if (result && typeof result === 'object' && 'ok' in result && result.ok === false) {
throw new Error(result.error);
}
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}, [bridge, refreshRuntimeState, setRuntimeState]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings) && effectiveRuntimeState.locked) {
const unlockedAt = Date.now();
void bridge?.requestAppLockUnlock?.('')
?.then((result) => {
if (result?.ok !== true) return;
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
return refreshRuntimeState();
})
.catch(() => {});
}
}, [bridge, effectiveRuntimeState.locked, normalizedSettings, refreshRuntimeState, setRuntimeState]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
const events: Array<keyof WindowEventMap> = ['pointerdown', 'keydown', 'wheel', 'touchstart', 'focus'];
for (const eventName of events) {
window.addEventListener(eventName, recordActivity, { passive: true });
}
return () => {
for (const eventName of events) {
window.removeEventListener(eventName, recordActivity);
}
};
}, [normalizedSettings, recordActivity]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
void bridge?.reportAppLockActivity?.();
return undefined;
}, [bridge, normalizedSettings]);
useEffect(() => {
void refreshSystemUnlockStatus();
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
// Stable identity: AppLockGate passes this to <App>, whose provider tree
// re-renders whenever it changes.
return useMemo(() => ({
initialized: effectiveRuntimeState.initialized,
locked: effectiveRuntimeState.locked,
lockReason: effectiveRuntimeState.reason,
lockNow,
unlock,
unlockWithSystemAuth,
systemUnlockStatus,
refreshSystemUnlockStatus,
reset,
recordActivity,
resync: refreshRuntimeState,
}), [
effectiveRuntimeState.initialized,
effectiveRuntimeState.locked,
effectiveRuntimeState.reason,
lockNow,
unlock,
unlockWithSystemAuth,
systemUnlockStatus,
refreshSystemUnlockStatus,
reset,
recordActivity,
refreshRuntimeState,
]);
}