Files
NetMesh/components/AppLockGate.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

187 lines
6.7 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { useAppLockBridge } from '../application/state/useAppLockBridge';
import { type AppLockReason, useAppLockState } from '../application/state/useAppLockState';
import { useSettingsState } from '../application/state/useSettingsState';
import { ToastProvider } from './ui/toast';
import { TooltipProvider } from './ui/tooltip';
import { AppLockOverlay } from './AppLockOverlay';
type SettingsState = ReturnType<typeof useSettingsState>;
type AppLockState = ReturnType<typeof useAppLockState>;
export interface AppLockGateRenderContext {
settings: SettingsState;
appLock: AppLockState;
}
interface AppLockGateProps {
children: (ctx: AppLockGateRenderContext) => React.ReactNode;
notifyRendererReady?: boolean;
/**
* Mount route children immediately (even before app-lock init / while locked).
* Terminal popups need this so their one-shot config IPC listeners register
* before main's post-loadURL send is only held in preload pending state —
* AppLockGate otherwise withholds children until async lock state resolves.
*/
forceRenderChildren?: boolean;
settingsOptions?: Parameters<typeof useSettingsState>[0];
}
interface AppLockGateDeps {
useSettingsState: typeof useSettingsState;
useAppLockState: typeof useAppLockState;
useAppLockBridge: typeof useAppLockBridge;
}
/**
* Withhold first content mount while the gate is locked for any reason
* (startup / idle / background / manual). A new renderer that reports ready
* before unlock would let main deliver ssh:// deep links into App handlers and
* start connections behind the lock overlay. Once children have mounted (e.g.
* idle re-lock after use), keep them mounted under the overlay.
*/
export function shouldRenderAppLockGateChildren(input: {
initialized: boolean;
locked: boolean;
lockReason: AppLockReason | null;
hasRenderedChildren: boolean;
forceRenderChildren?: boolean;
}): boolean {
if (input.forceRenderChildren) return true;
if (!input.initialized && !input.hasRenderedChildren) return false;
if (input.locked && !input.hasRenderedChildren) return false;
return true;
}
/**
* Deep-link delivery waits on rendererReady. Only mark the renderer ready once
* app children are allowed to mount, so unlock (not the 15s readiness timeout)
* is what unblocks pending ssh:// links after a locked first paint.
*/
export function shouldNotifyAppLockGateRendererReady(input: {
notifyRendererReady: boolean;
renderChildren: boolean;
}): boolean {
return input.notifyRendererReady && input.renderChildren;
}
/** Queue external actions (e.g. ssh:// deep links) while the app lock is held. */
export function shouldDeferExternalActionWhileAppLocked(input: {
locked: boolean;
}): boolean {
return input.locked === true;
}
export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGateProps> {
const AppLockGateImpl: React.FC<AppLockGateProps> = ({
children,
notifyRendererReady = true,
forceRenderChildren = false,
settingsOptions,
}) => {
const settings = deps.useSettingsState(settingsOptions);
const appLock = deps.useAppLockState(settings.appLockSettings);
const {
notifyRendererReady: notifyAppLockRendererReady,
onAppLockReopen,
} = deps.useAppLockBridge();
const hasRenderedChildrenRef = useRef(false);
const [reopenSignal, setReopenSignal] = useState(0);
const renderChildren = shouldRenderAppLockGateChildren({
initialized: appLock.initialized,
locked: appLock.locked,
lockReason: appLock.lockReason,
hasRenderedChildren: hasRenderedChildrenRef.current,
forceRenderChildren,
});
if (renderChildren) {
hasRenderedChildrenRef.current = true;
}
const shouldNotifyRendererReady = shouldNotifyAppLockGateRendererReady({
notifyRendererReady,
renderChildren,
});
useEffect(() => {
try {
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('fade-out');
setTimeout(() => splash.remove(), 200);
}
if (shouldNotifyRendererReady) {
notifyAppLockRendererReady();
}
} catch {
// ignore
}
}, [notifyAppLockRendererReady, shouldNotifyRendererReady]);
useEffect(() => {
const unsubscribe = onAppLockReopen(() => {
setReopenSignal((current) => current + 1);
void appLock.resync?.();
});
return () => unsubscribe?.();
}, [appLock, onAppLockReopen]);
useEffect(() => {
const handleVisibilityOrFocus = () => {
void appLock.resync?.();
};
window.addEventListener('focus', handleVisibilityOrFocus);
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
return () => {
window.removeEventListener('focus', handleVisibilityOrFocus);
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
};
}, [appLock]);
// When re-locked after first mount, children stay mounted under the overlay.
// Mark them inert so pointer/keyboard/AT cannot reach underlying controls
// (sessions, editors, side panels) while the lock screen is up.
const lockedBackground = appLock.locked === true;
return (
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<TooltipProvider delayDuration={300}>
{renderChildren ? (
<div
// React 19 supports the HTML inert attribute; when true, the
// subtree is non-interactive and removed from sequential focus.
inert={lockedBackground ? true : undefined}
aria-hidden={lockedBackground || undefined}
className={lockedBackground ? 'h-full pointer-events-none' : 'h-full'}
data-app-lock-background={lockedBackground ? 'locked' : 'unlocked'}
>
{children({ settings, appLock })}
</div>
) : null}
<AppLockOverlay
locked={appLock.locked}
reason={appLock.lockReason}
onUnlock={appLock.unlock}
systemUnlockStatus={appLock.systemUnlockStatus}
onSystemUnlock={appLock.unlockWithSystemAuth}
onResetAppLock={appLock.reset}
autoPromptSystemUnlock={settings.appLockSettings.systemUnlockAutoPromptEnabled}
reopenSignal={reopenSignal}
/>
</TooltipProvider>
</ToastProvider>
</I18nProvider>
);
};
return AppLockGateImpl;
}
export const AppLockGate = createAppLockGate({
useSettingsState,
useAppLockState,
useAppLockBridge,
});