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
883 lines
28 KiB
JavaScript
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,
|
|
};
|
|
}
|