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
196 lines
8.6 KiB
JavaScript
196 lines
8.6 KiB
JavaScript
/**
|
|
* Auto Launch - Registers Netcatty to start at system login, hidden to the
|
|
* tray. Thin wrapper around Electron's app.setLoginItemSettings/
|
|
* getLoginItemSettings so main.cjs and the settings IPC handlers share one
|
|
* source of truth.
|
|
*
|
|
* Development runs (`electron .`) are unsupported: process.execPath points
|
|
* at the transient electron.exe, so a login item registered there would
|
|
* break (or silently do nothing) after the dev process exits.
|
|
*
|
|
* Platform support is further limited to macOS and Windows: Electron's
|
|
* login-item API is a no-op on Linux, which ships as AppImage/deb/rpm/pacman
|
|
* with no first-party autostart hook, so reporting it as supported there
|
|
* would show an enabled toggle that does nothing.
|
|
*/
|
|
|
|
const HIDDEN_LAUNCH_ARG = "--hidden";
|
|
|
|
function isAutoLaunchSupported({ defaultApp = process.defaultApp, platform = process.platform } = {}) {
|
|
if (defaultApp) return false;
|
|
return platform === "darwin" || platform === "win32";
|
|
}
|
|
|
|
function argsEqual(a, b) {
|
|
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]);
|
|
}
|
|
|
|
/**
|
|
* Find the specific Windows run-key entry our own writes create (path +
|
|
* --hidden args), not just any entry for this executable.
|
|
*/
|
|
function findMatchingLaunchItem(settings, execPath) {
|
|
if (!Array.isArray(settings?.launchItems)) return null;
|
|
return settings.launchItems.find(
|
|
(item) => item?.path === execPath && argsEqual(item?.args, [HIDDEN_LAUNCH_ARG]),
|
|
) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the OS's effective auto-launch state, not just whether a
|
|
* registration exists.
|
|
*
|
|
* Windows can retain the run-key entry while the user disables it via Task
|
|
* Manager's Startup Apps UI. Electron's executableWillLaunchAtLogin surfaces
|
|
* an effective state too, but it deliberately ignores the args option and
|
|
* reports true if the executable would launch with ANY arguments — so it
|
|
* can false-positive on an unrelated no-argument entry for the same exe.
|
|
* launchItems[].enabled is scoped to one specific path+args registration
|
|
* (the same combination buildLoginItemQueryOptions queries), so look up our
|
|
* own --hidden entry there instead and use its enabled flag.
|
|
*/
|
|
function resolveEffectiveLoginState(settings, platform, execPath) {
|
|
if (platform === "win32") {
|
|
const matchingItem = findMatchingLaunchItem(settings, execPath);
|
|
if (matchingItem) return Boolean(matchingItem.enabled);
|
|
// No matching --hidden entry: openAtLogin was queried with the same
|
|
// path+args (see buildLoginItemQueryOptions), so it is still correctly
|
|
// scoped to "is that exact entry registered" — false if absent.
|
|
return Boolean(settings?.openAtLogin);
|
|
}
|
|
if (platform === "darwin" && settings?.status === "requires-approval") {
|
|
// macOS 13+ (SMAppService): a freshly registered login item sits in
|
|
// "requires-approval" until the user approves it in System Settings,
|
|
// during which openAtLogin reports false even though registration
|
|
// itself succeeded. Reporting false here would make the renderer's
|
|
// push effect see a mismatch against what the user just requested and
|
|
// immediately fire an opposite write, unregistering the pending item
|
|
// before the user ever gets a chance to approve it. Treat pending
|
|
// approval as enabled; a later read naturally reflects the real state
|
|
// once the user approves it (or the OS drops the pending request).
|
|
return true;
|
|
}
|
|
return Boolean(settings?.openAtLogin);
|
|
}
|
|
|
|
/**
|
|
* Electron's getLoginItemSettings() only reports openAtLogin for the
|
|
* specific path+args combination you ask about — it does not mean "is
|
|
* anything registered for this app". Our code only ever registers a login
|
|
* item with args:[HIDDEN_LAUNCH_ARG], so every read must query that exact
|
|
* combination (matching what setAutoLaunchEnabled writes) or Windows
|
|
* reports a false negative.
|
|
*/
|
|
function buildLoginItemQueryOptions(execPath) {
|
|
return { path: execPath, args: [HIDDEN_LAUNCH_ARG] };
|
|
}
|
|
|
|
function getAutoLaunchEnabled({
|
|
app,
|
|
execPath = process.execPath,
|
|
defaultApp = process.defaultApp,
|
|
platform = process.platform,
|
|
} = {}) {
|
|
if (!isAutoLaunchSupported({ defaultApp, platform })) {
|
|
return { success: true, enabled: false, supported: false };
|
|
}
|
|
try {
|
|
const settings = app.getLoginItemSettings(buildLoginItemQueryOptions(execPath));
|
|
return { success: true, enabled: resolveEffectiveLoginState(settings, platform, execPath), supported: true };
|
|
} catch (err) {
|
|
console.warn("[AutoLaunch] Failed to read login item settings:", err?.message || err);
|
|
// success:false signals "OS state unknown" (as opposed to a confirmed
|
|
// disabled state) so callers — notably the renderer's mount-time
|
|
// hydration — know not to trust `enabled` here. Blindly applying
|
|
// enabled:false on a transient read failure would overwrite the
|
|
// renderer's cached value and cascade into an unwanted disable write.
|
|
return { success: false, enabled: false, supported: true };
|
|
}
|
|
}
|
|
|
|
function setAutoLaunchEnabled(enabled, {
|
|
app,
|
|
execPath = process.execPath,
|
|
defaultApp = process.defaultApp,
|
|
platform = process.platform,
|
|
} = {}) {
|
|
if (!isAutoLaunchSupported({ defaultApp, platform })) {
|
|
// enabled:false is a confirmed fact here (the feature genuinely isn't
|
|
// available), not an unknown state — matches getAutoLaunchEnabled's
|
|
// equivalent branch so both functions agree on what `success` means.
|
|
return { success: true, enabled: false, supported: false };
|
|
}
|
|
const wantEnabled = Boolean(enabled);
|
|
try {
|
|
app.setLoginItemSettings({
|
|
openAtLogin: wantEnabled,
|
|
// openAsHidden only applies on macOS App Store builds; Windows relies
|
|
// on the --hidden arg below, which main.cjs checks on cold start.
|
|
openAsHidden: wantEnabled,
|
|
path: execPath,
|
|
args: wantEnabled ? [HIDDEN_LAUNCH_ARG] : [],
|
|
});
|
|
const settings = app.getLoginItemSettings(buildLoginItemQueryOptions(execPath));
|
|
return { success: true, enabled: resolveEffectiveLoginState(settings, platform, execPath), supported: true };
|
|
} catch (err) {
|
|
console.warn("[AutoLaunch] Failed to update login item settings:", err?.message || err);
|
|
// `success` means "is `enabled` trustworthy", not "did the write
|
|
// succeed" — the write itself failing does not make a subsequent,
|
|
// independent read failing too. Surface whatever the fallback read
|
|
// determines: if it succeeds, `enabled` reflects the real (unwritten)
|
|
// state and the renderer's push effect correctly rolls the optimistic
|
|
// toggle back instead of leaving it stuck on a change that never
|
|
// actually happened. Only a genuine double failure (write AND fallback
|
|
// read both throw) reports success:false, so the renderer preserves its
|
|
// last-known state instead of guessing.
|
|
const fallback = getAutoLaunchEnabled({ app, execPath, defaultApp, platform });
|
|
return { success: fallback.success, enabled: fallback.enabled, supported: true };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when this process was launched by the OS login item (cold start
|
|
* only). Windows relies on the --hidden arg (macOS never puts args from
|
|
* setLoginItemSettings() into argv for a login launch, so it needs a
|
|
* different signal).
|
|
*
|
|
* macOS's own hidden-launch flags (openAsHidden/wasOpenedAsHidden) are
|
|
* deprecated and stop working on macOS 13+ per Electron's docs, so they
|
|
* cannot be trusted to detect an actual hidden launch there. wasOpenedAtLogin
|
|
* still works on 13+ and is not deprecated; since Netcatty only ever
|
|
* registers a macOS login item to satisfy this "launch hidden" feature (there
|
|
* is no scenario where it registers one for a normal, visible startup), any
|
|
* automatic login launch should apply our own hidden-window behavior.
|
|
*/
|
|
function wasLaunchedHidden({ argv = process.argv, app, platform = process.platform } = {}) {
|
|
if (Array.isArray(argv) && argv.includes(HIDDEN_LAUNCH_ARG)) return true;
|
|
if (platform !== "darwin" || typeof app?.getLoginItemSettings !== "function") return false;
|
|
try {
|
|
return Boolean(app.getLoginItemSettings().wasOpenedAtLogin);
|
|
} catch (err) {
|
|
console.warn("[AutoLaunch] Failed to read macOS login-item launch state:", err?.message || err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function registerHandlers(ipcMain, { app, platform = process.platform }) {
|
|
ipcMain.handle("netcatty:autoLaunch:get", async () => {
|
|
return getAutoLaunchEnabled({ app, platform });
|
|
});
|
|
|
|
ipcMain.handle("netcatty:autoLaunch:set", async (_event, { enabled }) => {
|
|
return setAutoLaunchEnabled(enabled, { app, platform });
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
HIDDEN_LAUNCH_ARG,
|
|
isAutoLaunchSupported,
|
|
resolveEffectiveLoginState,
|
|
buildLoginItemQueryOptions,
|
|
getAutoLaunchEnabled,
|
|
setAutoLaunchEnabled,
|
|
wasLaunchedHidden,
|
|
registerHandlers,
|
|
};
|