Files
NetMesh/electron/bridges/appLockRuntimeBridge.cjs
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

883 lines
28 KiB
JavaScript

const {
canLockFromSettings,
createAppLockPasswordVerifier,
normalizeAppLockTimeoutMinutes,
shouldLockOnBackgroundHide,
verifyAppLockPassword,
} = require("./appLockSettingsStore.cjs");
function normalizeReason(reason) {
return typeof reason === "string" && reason.trim() !== "" ? reason : null;
}
function cloneState(state) {
return {
initialized: state.initialized === true,
locked: state.locked === true,
reason: normalizeReason(state.reason),
version: state.version,
lastLockedAt: typeof state.lastLockedAt === "number" ? state.lastLockedAt : null,
lastUnlockedAt: typeof state.lastUnlockedAt === "number" ? state.lastUnlockedAt : null,
lastActivityAt: typeof state.lastActivityAt === "number" ? state.lastActivityAt : null,
};
}
function createAppLockRuntimeBridge() {
let state = {
initialized: false,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: null,
lastActivityAt: null,
};
const listeners = new Set();
let idleTimerId = null;
let idleTimerConfig = null;
function getState() {
return cloneState(state);
}
function notify() {
const snapshot = getState();
listeners.forEach((listener) => {
try {
listener(snapshot);
} catch {
// ignore subscriber failures
}
});
}
function clearScheduledTimerOnly() {
if (idleTimerId !== null) {
clearTimeout(idleTimerId);
idleTimerId = null;
}
}
function getTimeoutMs(timeoutMinutes) {
if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) return null;
return timeoutMinutes * 60_000;
}
function rescheduleIdleTimer() {
clearScheduledTimerOnly();
if (!idleTimerConfig || state.initialized !== true || state.locked === true) {
return;
}
const timeoutMs = getTimeoutMs(idleTimerConfig.timeoutMinutes);
if (timeoutMs === null || typeof state.lastActivityAt !== "number") {
return;
}
const elapsedMs = Math.max(0, Date.now() - state.lastActivityAt);
const delayMs = Math.max(0, timeoutMs - elapsedMs);
idleTimerId = setTimeout(() => {
idleTimerId = null;
if (!idleTimerConfig || state.initialized !== true || state.locked === true) {
return;
}
const currentTimeoutMs = getTimeoutMs(idleTimerConfig.timeoutMinutes);
if (currentTimeoutMs === null || typeof state.lastActivityAt !== "number") {
return;
}
const currentElapsedMs = Math.max(0, Date.now() - state.lastActivityAt);
if (currentElapsedMs < currentTimeoutMs) {
rescheduleIdleTimer();
return;
}
if (!idleTimerConfig.canLock()) {
clearIdleTimer();
return;
}
const nextState = lock("idle");
try {
idleTimerConfig.onIdleLock(nextState);
} catch {
// ignore callback failures
}
}, delayMs);
if (idleTimerId && typeof idleTimerId.unref === "function") {
idleTimerId.unref();
}
}
function applyStatePatch(patch, { notifyListeners = true } = {}) {
state = {
...state,
...patch,
version: state.version + 1,
};
rescheduleIdleTimer();
if (notifyListeners) {
notify();
}
return getState();
}
function initialize(nextState) {
const now = Date.now();
const locked = nextState?.locked === true;
return applyStatePatch({
initialized: true,
locked,
reason: locked ? normalizeReason(nextState?.reason) || "startup" : null,
lastLockedAt: locked
? (typeof nextState?.lastLockedAt === "number" ? nextState.lastLockedAt : now)
: null,
lastUnlockedAt: locked
? null
: (typeof nextState?.lastUnlockedAt === "number" ? nextState.lastUnlockedAt : null),
lastActivityAt: typeof nextState?.lastActivityAt === "number" ? nextState.lastActivityAt : now,
});
}
function lock(reason = "manual") {
const nextReason = normalizeReason(reason) || "manual";
if (state.initialized === true && state.locked === true && state.reason === nextReason) {
return getState();
}
return applyStatePatch({
initialized: true,
locked: true,
reason: nextReason,
lastLockedAt: Date.now(),
});
}
function unlock() {
const now = Date.now();
if (state.initialized === true && state.locked === false && state.reason === null) {
return getState();
}
return applyStatePatch({
initialized: true,
locked: false,
reason: null,
lastUnlockedAt: now,
lastActivityAt: now,
});
}
function recordActivity(timestamp = Date.now()) {
if (state.initialized !== true || state.locked === true) {
return getState();
}
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
return getState();
}
if (state.lastActivityAt === timestamp) {
return getState();
}
return applyStatePatch(
{
lastActivityAt: timestamp,
},
{ notifyListeners: false },
);
}
function subscribe(listener) {
if (typeof listener !== "function") {
return () => {};
}
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function scheduleIdleTimer({ timeoutMinutes, canLock, onIdleLock }) {
idleTimerConfig = {
timeoutMinutes,
canLock: typeof canLock === "function" ? canLock : () => true,
onIdleLock: typeof onIdleLock === "function" ? onIdleLock : () => {},
};
rescheduleIdleTimer();
}
function clearIdleTimer() {
idleTimerConfig = null;
clearScheduledTimerOnly();
}
return {
initialize,
getState,
lock,
unlock,
recordActivity,
subscribe,
scheduleIdleTimer,
clearIdleTimer,
};
}
module.exports = {
createAppLockController,
createAppLockRuntimeBridge,
};
function createAppLockController({
settingsStore,
runtimeBridge,
systemAuthBridge = null,
getMainWindows = () => [],
// Session windows use registerAsMainWindow:false and are only tracked as
// app-content windows; they still mount AppLockGate and must receive lock
// runtime / settings broadcasts (Codex P1).
getAppContentWindows = () => [],
getSettingsWindow = () => null,
getTrayPanelWindow = () => null,
getTerminalPopupWindows = () => [],
waitForUnlockFailureDelay = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
}) {
if (!settingsStore || typeof settingsStore.getSnapshot !== "function" || typeof settingsStore.save !== "function") {
throw new Error("createAppLockController requires a settingsStore");
}
if (!runtimeBridge || typeof runtimeBridge.getState !== "function") {
throw new Error("createAppLockController requires a runtimeBridge");
}
// Serialize full read-modify-write settings mutations (Codex P2).
let settingsMutationChain = Promise.resolve();
// Single in-flight system-auth prompt shared across windows (Codex P2).
let systemUnlockInFlight = null;
let passwordUnlockInFlight = null;
let passwordUnlockFailureCount = 0;
const lockedWindowTitles = new Map();
const protectedWindows = new WeakSet();
function syncIdleTimer() {
const settings = getSettings();
if (!canLockFromSettings(settings)) {
runtimeBridge.clearIdleTimer?.();
return;
}
runtimeBridge.scheduleIdleTimer?.({
timeoutMinutes: settings.timeoutMinutes,
canLock: () => canLockFromSettings(getSettings()),
onIdleLock: (nextState) => {
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
},
});
}
function getWindowsForBroadcast() {
const windows = [
...(Array.isArray(getMainWindows()) ? getMainWindows() : []),
// Detached #/session-window and other app-content-only windows.
...(Array.isArray(getAppContentWindows()) ? getAppContentWindows() : []),
getSettingsWindow(),
getTrayPanelWindow(),
...(Array.isArray(getTerminalPopupWindows()) ? getTerminalPopupWindows() : []),
];
const seen = new Set();
return windows.filter((win) => {
if (!win || typeof win.isDestroyed !== "function" || win.isDestroyed()) return false;
if (!win.webContents || typeof win.webContents.isDestroyed !== "function" || win.webContents.isDestroyed()) {
return false;
}
const id = win.webContents.id || win;
if (seen.has(id)) return false;
seen.add(id);
return true;
});
}
function broadcast(channel, payload) {
for (const win of getWindowsForBroadcast()) {
try {
win.webContents.send(channel, payload);
} catch {
// ignore disposed windows during broadcast
}
}
}
function enforceWindowProtection(win) {
if (!win || win.isDestroyed?.() || getRuntimeState()?.locked !== true) return;
try {
if (typeof win.getTitle === "function") {
const currentTitle = win.getTitle();
if (currentTitle && currentTitle !== "NetMesh") {
lockedWindowTitles.set(win, currentTitle);
}
}
win.setTitle?.("NetMesh");
if (win.webContents?.isDevToolsOpened?.()) {
win.webContents.closeDevTools?.();
}
} catch {
// ignore per-window failures during lock transitions
}
}
// Windows shows the native title-bar menu (Minimize / Maximize / Close) on
// right-click in -webkit-app-region: drag areas. The lock overlay is a drag
// region so the window stays movable, so hide that menu while locked.
function suppressSystemContextMenu(event) {
if (getRuntimeState()?.locked !== true) return;
try {
event?.preventDefault?.();
} catch {
// ignore
}
}
function protectWindow(win) {
if (!win || win.isDestroyed?.()) return;
if (!protectedWindows.has(win)) {
protectedWindows.add(win);
const enforce = () => enforceWindowProtection(win);
try { win.on?.("show", enforce); } catch { /* ignore */ }
try { win.on?.("focus", enforce); } catch { /* ignore */ }
try { win.on?.("system-context-menu", suppressSystemContextMenu); } catch { /* ignore */ }
try { win.webContents?.on?.("devtools-opened", enforce); } catch { /* ignore */ }
try {
win.webContents?.on?.("did-finish-load", () => {
queueMicrotask(enforce);
});
} catch {
// ignore
}
}
enforceWindowProtection(win);
}
function setWindowTitle(win, title) {
if (!win || win.isDestroyed?.()) return false;
const nextTitle = typeof title === "string" && title.trim() ? title.trim() : "NetMesh";
try {
if (getRuntimeState()?.locked === true) {
lockedWindowTitles.set(win, nextTitle);
win.setTitle?.("NetMesh");
return false;
}
win.setTitle?.(nextTitle);
return true;
} catch {
return false;
}
}
function protectWindowsForRuntimeState(nextState) {
if (nextState?.locked === true) {
for (const win of getWindowsForBroadcast()) protectWindow(win);
return;
}
for (const [win, title] of lockedWindowTitles) {
try {
if (!win.isDestroyed?.() && win.getTitle?.() === "Netcatty") {
win.setTitle?.(title || "Netcatty");
}
} catch {
// ignore disposed windows during unlock
}
}
lockedWindowTitles.clear();
}
// Runtime transitions are the single lock boundary. Idle, background,
// startup, and manual locks all pass through this subscription.
runtimeBridge.subscribe?.(protectWindowsForRuntimeState);
// initialize() can lock the runtime before the controller exists.
protectWindowsForRuntimeState(runtimeBridge.getState());
function getSettings() {
return settingsStore.getSnapshot();
}
/**
* Renderer-facing settings replace real salt/hash with zeroed placeholders of
* the correct size so presence checks still work, but offline brute-force of
* the verifier from any renderer is impossible (Codex P2 on 8c0b9c5a).
* Password verification always runs in main against the private store.
*/
function getPublicSettings() {
const settings = getSettings();
if (!settings || typeof settings !== "object") return settings;
if (!settings.passwordVerifier) return settings;
const redactedSalt = Buffer.alloc(16).toString("base64");
const redactedHash = Buffer.alloc(32).toString("base64");
return {
...settings,
passwordVerifier: {
version: settings.passwordVerifier.version,
algorithm: settings.passwordVerifier.algorithm,
iterations: settings.passwordVerifier.iterations,
salt: redactedSalt,
hash: redactedHash,
},
};
}
function getRuntimeState() {
return runtimeBridge.getState();
}
async function getSystemAuthStatusOnly() {
if (!systemAuthBridge || typeof systemAuthBridge.getStatus !== "function") {
return {
supported: false,
available: false,
platform: "unsupported",
label: null,
reason: null,
};
}
try {
return await systemAuthBridge.getStatus();
} catch {
return {
supported: false,
available: false,
platform: "unsupported",
label: null,
reason: "failed",
};
}
}
async function getSystemUnlockStatus() {
const status = await getSystemAuthStatusOnly();
const settings = getSettings();
const canLock = canLockFromSettings(settings);
return {
supported: status.supported === true,
available: status.available === true && canLock,
enabled: settings.systemUnlockEnabled === true && canLock,
platform: status.platform || "unsupported",
label: status.label || null,
reason: status.reason || null,
};
}
async function saveSettings(nextSettings) {
const saved = await settingsStore.save(nextSettings);
syncIdleTimer();
const publicSaved = getPublicSettings();
broadcast("netcatty:appLock:settingsChanged", publicSaved);
return publicSaved;
}
/** Queue a full RMW settings mutation so concurrent changes cannot clobber. */
function mutateSettings(mutator) {
const run = async () => {
const current = getSettings();
const next = await mutator(current);
if (!next) return getPublicSettings();
return saveSettings(next);
};
const pending = settingsMutationChain.then(run, run);
settingsMutationChain = pending.then(() => {}, () => {});
return pending;
}
async function requestEnable() {
return mutateSettings(async (current) => {
if (!current.passwordVerifier) return null;
return {
...current,
enabled: true,
};
});
}
async function requestDisable(currentPassword) {
// Full RMW on the mutation queue so a concurrent setTimeoutMinutes cannot
// snapshot pre-disable settings and re-enable App Lock after us (Codex P2).
let fail = null;
const saved = await mutateSettings(async (current) => {
if (current.passwordVerifier) {
if (!currentPassword) {
fail = { ok: false, error: "empty-current" };
return null;
}
const verified = await verifyAppLockPassword(currentPassword, current.passwordVerifier);
if (!verified) {
fail = { ok: false, error: "incorrect" };
return null;
}
}
return {
...current,
enabled: false,
passwordVerifier: null,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
};
});
if (fail) return fail;
const runtimeState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", runtimeState);
return saved;
}
async function requestReset(currentPassword) {
let fail = null;
const saved = await mutateSettings(async (current) => {
const verified = await verifyCurrentPassword(current, currentPassword);
if (verified !== true) {
fail = verified;
return null;
}
return {
...current,
enabled: false,
passwordVerifier: null,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
};
});
if (fail) return fail;
const runtimeState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", runtimeState);
return saved;
}
async function requestPasswordChange(input = {}) {
const nextPassword = typeof input.nextPassword === "string" ? input.nextPassword : "";
const currentPassword = typeof input.currentPassword === "string" ? input.currentPassword : "";
const hadVerifierAtRequest = Boolean(getSettings().passwordVerifier);
if (nextPassword.length === 0) {
return { ok: false, error: "empty-next" };
}
let fail = null;
let enablingFromNoVerifier = false;
const saved = await mutateSettings(async (current) => {
// A password-change request must not turn into a first-time enable if a
// disable/reset queued ahead of it removed the verifier.
if (hadVerifierAtRequest && !current.passwordVerifier) {
fail = { ok: false, error: "incorrect" };
return null;
}
if (current.passwordVerifier) {
if (!currentPassword) {
fail = { ok: false, error: "empty-current" };
return null;
}
const verified = await verifyAppLockPassword(currentPassword, current.passwordVerifier);
if (!verified) {
fail = { ok: false, error: "incorrect" };
return null;
}
}
enablingFromNoVerifier = !current.passwordVerifier;
const passwordVerifier = await createAppLockPasswordVerifier(nextPassword);
return {
...current,
enabled: current.enabled || enablingFromNoVerifier,
passwordVerifier,
};
});
if (fail) return fail;
// While lock was disabled the renderer never reported activity. Re-arming
// the idle timer on enable would schedule an immediate lock if Netcatty
// has been open longer than the timeout. Record fresh activity first
// (Codex P2 on dbe1a746).
if (enablingFromNoVerifier) {
try { runtimeBridge.recordActivity(Date.now()); } catch { /* ignore */ }
syncIdleTimer();
}
return saved;
}
async function setTimeoutMinutes(timeoutMinutes) {
return mutateSettings(async (current) => ({
...current,
timeoutMinutes: normalizeAppLockTimeoutMinutes(timeoutMinutes),
}));
}
async function verifyCurrentPassword(current, currentPassword) {
if (!current.passwordVerifier) return true;
if (!currentPassword) return { ok: false, error: "empty-current" };
const verified = await verifyAppLockPassword(currentPassword, current.passwordVerifier);
if (!verified) return { ok: false, error: "incorrect" };
return true;
}
async function setSystemUnlockEnabled(input = {}) {
const enabled = input?.enabled === true;
const autoPromptEnabled = input?.autoPromptEnabled === true;
const currentPassword = typeof input?.currentPassword === "string" ? input.currentPassword : "";
const current = getSettings();
if (!canLockFromSettings(current)) {
return { ok: false, error: "unavailable" };
}
if (!enabled) {
if (runtimeBridge.getState().locked === true && !currentPassword) {
return { ok: false, error: "locked" };
}
if (currentPassword) {
const verified = await verifyCurrentPassword(current, currentPassword);
if (verified !== true) return verified;
}
return mutateSettings(async (latest) => ({
...latest,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
}));
}
if (current.systemUnlockEnabled === true) {
return mutateSettings(async (latest) => ({
...latest,
systemUnlockAutoPromptEnabled: autoPromptEnabled,
}));
}
const status = await getSystemAuthStatusOnly();
if (status.supported !== true) return { ok: false, error: "unsupported" };
if (status.available !== true) return { ok: false, error: "unavailable" };
if (!systemAuthBridge || typeof systemAuthBridge.requestUnlock !== "function") {
return { ok: false, error: "unsupported" };
}
const result = await systemAuthBridge.requestUnlock();
if (!result || result.ok !== true) {
return {
ok: false,
error: result?.error || "failed",
};
}
return mutateSettings(async (latest) => ({
...latest,
systemUnlockEnabled: true,
systemUnlockAutoPromptEnabled: autoPromptEnabled,
}));
}
function setLocked(reason) {
const settings = getSettings();
if (!canLockFromSettings(settings)) {
return getRuntimeState();
}
// Close-to-tray and app-hide use reason "background". Honor "Never
// lock automatically" so hiding the window does not prompt for a password.
if (reason === "background" && !shouldLockOnBackgroundHide(settings)) {
return getRuntimeState();
}
const nextState = runtimeBridge.lock(reason);
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
return nextState;
}
function isSamePasswordVerifier(a, b) {
return Boolean(
a
&& b
&& a.version === b.version
&& a.algorithm === b.algorithm
&& a.iterations === b.iterations
&& a.salt === b.salt
&& a.hash === b.hash
);
}
async function rejectPasswordUnlockAttempt() {
passwordUnlockFailureCount += 1;
const delayMs = 250 * (2 ** Math.min(passwordUnlockFailureCount - 1, 3));
await waitForUnlockFailureDelay(delayMs);
return { ok: false, error: "incorrect" };
}
async function requestUnlockAttempt(password, requestContext) {
const current = getSettings();
if (!canLockFromSettings(current)) {
passwordUnlockFailureCount = 0;
const nextState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
return { ok: true };
}
if (!password) {
return { ok: false, error: "empty" };
}
const lockAtAttempt = requestContext.lockState;
if (
lockAtAttempt.locked !== true
|| !isSamePasswordVerifier(requestContext.passwordVerifier, current.passwordVerifier)
) {
return rejectPasswordUnlockAttempt();
}
const verified = await verifyAppLockPassword(password, requestContext.passwordVerifier);
const latest = getSettings();
const lockAfterVerify = runtimeBridge.getState();
const staleAttempt = !isSamePasswordVerifier(requestContext.passwordVerifier, latest.passwordVerifier)
|| lockAfterVerify.locked !== true
|| lockAfterVerify.version !== lockAtAttempt.version;
if (!verified || staleAttempt) {
return rejectPasswordUnlockAttempt();
}
passwordUnlockFailureCount = 0;
const nextState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
return { ok: true };
}
function requestUnlock(password) {
const current = getSettings();
const requestContext = {
lockState: runtimeBridge.getState(),
passwordVerifier: current.passwordVerifier,
};
if (!canLockFromSettings(current)) {
return requestUnlockAttempt(password, requestContext);
}
// Calls made while unlocked must never enter a queue that can delay a
// later legitimate unlock after idle/background locking.
if (requestContext.lockState.locked !== true) {
return Promise.resolve({ ok: false, error: "incorrect" });
}
// At most one expensive password attempt may be active. Concurrent IPC
// calls share it instead of creating an unbounded PBKDF/backoff queue.
if (passwordUnlockInFlight) return passwordUnlockInFlight;
passwordUnlockInFlight = requestUnlockAttempt(password, requestContext);
return passwordUnlockInFlight.finally(() => {
passwordUnlockInFlight = null;
});
}
async function requestSystemUnlock() {
if (systemUnlockInFlight) {
return systemUnlockInFlight;
}
systemUnlockInFlight = (async () => {
const current = getSettings();
if (!canLockFromSettings(current)) return { ok: false, error: "unavailable" };
if (current.systemUnlockEnabled !== true) return { ok: false, error: "disabled" };
// Capture the lock presentation epoch before the OS prompt. A re-lock
// (idle → background) advances version while staying locked; accepting a
// stale prompt would unlock the newer lock (Codex P2).
const lockAtPrompt = runtimeBridge.getState();
if (lockAtPrompt.locked !== true) return { ok: false, error: "not-locked" };
const lockVersionAtPrompt = lockAtPrompt.version;
const status = await getSystemAuthStatusOnly();
if (status.supported !== true) return { ok: false, error: "unsupported" };
if (status.available !== true) return { ok: false, error: "unavailable" };
if (!systemAuthBridge || typeof systemAuthBridge.requestUnlock !== "function") {
return { ok: false, error: "unsupported" };
}
const result = await systemAuthBridge.requestUnlock();
if (!result || result.ok !== true) {
return {
ok: false,
error: result?.error || "failed",
};
}
const latestSettings = getSettings();
if (
!canLockFromSettings(latestSettings)
|| latestSettings.systemUnlockEnabled !== true
) {
return { ok: false, error: "disabled" };
}
// Drop the prompt result if we unlocked, re-locked, or the lock reason
// changed while the dialog was open (version advances on each lock patch).
const lockAfterPrompt = runtimeBridge.getState();
if (
lockAfterPrompt.locked !== true
|| lockAfterPrompt.version !== lockVersionAtPrompt
) {
return { ok: false, error: "not-locked" };
}
const nextState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
return { ok: true };
})();
try {
return await systemUnlockInFlight;
} finally {
systemUnlockInFlight = null;
}
}
function reportActivity(timestamp = Date.now()) {
const nextState = runtimeBridge.recordActivity(timestamp);
syncIdleTimer();
return nextState;
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:appLock:getRuntimeState", () => getRuntimeState());
ipcMain.handle("netcatty:appLock:getSettings", () => getPublicSettings());
ipcMain.handle("netcatty:appLock:setTimeoutMinutes", (_event, timeoutMinutes) =>
setTimeoutMinutes(timeoutMinutes));
ipcMain.handle("netcatty:appLock:requestEnable", () => requestEnable());
ipcMain.handle("netcatty:appLock:requestDisable", (_event, currentPassword) =>
requestDisable(currentPassword));
ipcMain.handle("netcatty:appLock:requestReset", (_event, currentPassword) =>
requestReset(currentPassword));
ipcMain.handle("netcatty:appLock:requestPasswordChange", (_event, input) =>
requestPasswordChange(input));
ipcMain.handle("netcatty:appLock:setLocked", (_event, reason) => setLocked(reason));
ipcMain.handle("netcatty:appLock:requestUnlock", (_event, password) =>
requestUnlock(password));
ipcMain.handle("netcatty:appLock:getSystemUnlockStatus", () =>
getSystemUnlockStatus());
ipcMain.handle("netcatty:appLock:setSystemUnlockEnabled", (_event, input) =>
setSystemUnlockEnabled(input));
ipcMain.handle("netcatty:appLock:requestSystemUnlock", () =>
requestSystemUnlock());
ipcMain.handle("netcatty:appLock:reportActivity", () => reportActivity());
}
return {
getSettings,
getRuntimeState,
/** Subscribe to runtime lock/unlock transitions (used by tray deferral). */
subscribe: (listener) => runtimeBridge.subscribe(listener),
requestEnable,
requestDisable,
requestReset,
requestPasswordChange,
setTimeoutMinutes,
setLocked,
requestUnlock,
getSystemUnlockStatus,
setSystemUnlockEnabled,
requestSystemUnlock,
reportActivity,
protectWindow,
setWindowTitle,
registerHandlers,
syncIdleTimer,
};
}