diff --git a/AGENTS.md b/AGENTS.md
index 1124b4c..b23cfae 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,13 +38,20 @@ missing one fails at runtime only.
- **`src/run-log.ts`** — the per-run step log behind the Activity panel. One
run at a time; `refresh()` guards on `isWorking` because a second run would
overwrite the current-run slot.
-- **`src/schedule.ts`** — every delay `setNextTokenRefresh()` may use. Three
- rules: a retry delay is never derived from the stored token expiry (after a
+- **`src/schedule.ts`** — every delay `setNextTokenRefresh()` may use, plus
+ `loginRetryAction()`, the decision behind replacing a login nobody finished.
+ Four rules: a delay is never derived from the stored token expiry (after a
failure it is in the past, which collapses to the floor and reopens the login
- page in a loop); a login nobody completed is not retried on a timer; a
- failure *after* the token was renewed keeps the expiry schedule rather than
- an error retry, which would reopen the login page for an unrelated failure.
- No electron imports.
+ page in a loop, #83); a failure *after* the token was renewed keeps the expiry
+ schedule rather than an error retry, which would reopen the login page for an
+ unrelated failure; only the user ending a login themselves
+ (`cancelledByUser`) stops it being replaced; and a login nobody finished is
+ replaced with **no backoff at all** — continuous refreshing is the premise, so
+ a dead device code is replaced by a live one for as long as it takes.
+ `MIN_LOGIN_CYCLE_MS` is a floor, not a backoff: it never grows, and nothing
+ waits for it unless a login fails the instant it starts. What keeps that from
+ piling up login pages is that an attempt with nobody at the machine never
+ shows anything (see automatic approval). No electron imports.
- **`src/page-script.ts`** — `loadPageScript()` / `injectIntoEveryFrame()`,
used by both injected scripts. Injection follows sub-frames because
`executeJavaScript` on a `WebContents` reaches the top frame only, and a
@@ -180,6 +187,17 @@ of them says the user is needed (issue #1). Keep these true:
default-browser mode, `shell.openExternal`, destroying the hidden probe only
once the browser is up. A new way for the flow to end without arriving there
is a refresh that hangs invisibly until the device code expires.
+- **Unattended, "somewhere" is a hold, not the screen.** When nobody is at the
+ machine (`powerMonitor.getSystemIdleTime()`, see `AWAY_IDLE_SEC`), the same
+ `onUserNeeded` *parks* the attempt — nothing is shown, the page keeps being
+ driven, and the poll loop hands it over the moment somebody is there, as does
+ a refresh the user asks for (`showParkedLogin`, which is why `refresh()` no
+ longer just skips a run in progress). A login page opened into an empty room
+ is dead in ten minutes, which is what made an overnight refresh a morning of
+ expired credentials. Parking rather than aborting is deliberate twice over: a
+ slow identity provider can still come through on its own, and the user who
+ returns gets a page that is already loaded. Presence is re-read at each poll,
+ never captured once per run.
- **`backgroundThrottling: false` on the window.** Chromium throttles timers in
a window that is not visible, and the driver's scan loop is a timer.
- **Clicking is deliberately narrow.** Only on the device-authorization hosts
@@ -546,8 +564,12 @@ Worth doing headlessly, since nothing else covers it:
`AWS_IAM_AUTHENTICATOR_PATH` at a stub that prints an `ExecCredential` and
kubectl walks the whole exec path with no AWS account. Cover an existing
config carrying an entry the old loader rejected — a context with no cluster.
-- **`src/schedule.ts`**: pure and electron-free, so its delay arithmetic can be
- exercised directly.
+- **`src/schedule.ts`**: pure and electron-free, so its delay arithmetic and
+ `loginRetryAction()` can be exercised directly. The behaviour around it —
+ which refreshes are unattended, what an unattended one is allowed to put on
+ screen, when a retry fires — needs `dist/aws-sso.js` loaded against stubbed
+ `electron` (including `powerMonitor.getSystemIdleTime`), a faked SSO OIDC
+ client and a fake clock, driving a whole night in a second.
Windows behaviour — Squirrel install and update events, the tray icon, toast
notifications, the login item — needs a real Windows machine. CI proves the
diff --git a/docs/docs/app-window.html b/docs/docs/app-window.html
index 7d83e4c..98d4a68 100644
--- a/docs/docs/app-window.html
+++ b/docs/docs/app-window.html
@@ -54,6 +54,8 @@
+ Closing the login window or refusing the sign-in at your identity
+ provider is different: that is you saying "not now", so Frost stops,
+ quietly, and the next attempt is the one you start from the tray, the
Credentials page or the hotkey.
diff --git a/docs/docs/settings-behavior.html b/docs/docs/settings-behavior.html
index cbfc8f0..7c7c751 100644
--- a/docs/docs/settings-behavior.html
+++ b/docs/docs/settings-behavior.html
@@ -70,7 +70,10 @@
The cost is that credentials stay expired until you act — commands fail
with ExpiredToken in the meantime — and if you never act, the
- run gives up when AWS expires the device code and tries again later.
+ run ends when AWS expires the device code and Frost posts a fresh
+ notification for the sign-in that replaces it. A refresh that comes due
+ while you are away waits for you rather than notifying an empty room, so
+ the code behind the notification you do get is always a live one.
💡
diff --git a/docs/docs/troubleshooting.html b/docs/docs/troubleshooting.html
index fb5e4cd..b9be2d9 100644
--- a/docs/docs/troubleshooting.html
+++ b/docs/docs/troubleshooting.html
@@ -54,28 +54,53 @@
Sign-in fails or never completes
"Login window closed"
The login window was closed before AWS confirmed the approval. Frost
- treats that as "not now" and aborts the run. It does not start another
- one on its own — trigger a refresh from the tray or the Credentials page
+ treats that as "not now" and aborts the run. This is the one ending it
+ does not retry — trigger a refresh from the tray or the Credentials page
when you are ready.
"Login timed out"
- The device code AWS issued expired before the sign-in was approved. Frost
- stops there and the tray menu shows Sign-in needed; start
- another refresh when you are back. If this happens repeatedly in notify
- mode, you may simply not be getting the notification —
+ The device code AWS issued expired before the sign-in was approved — ten
+ minutes, typically. Frost starts another sign-in immediately, so a run
+ like this repeating every ten minutes while you were away is Frost
+ working as intended, not a fault: each entry is one device code's life.
+ If it happens repeatedly in notify mode while you are at the
+ machine, you may simply not be getting the notification —
see below.
+
"Nobody is at the machine, and this login needs the user"
+
+ Only in Notification + hotkey mode, or with automatic
+ approval turned off: neither can get through without you, so rather than
+ ask AWS for a device code nobody can redeem, Frost waits and starts the
+ sign-in when you are back — within half a minute of your first keypress.
+ Nothing is wrong, and there is one entry for it rather than one every
+ half minute.
+
+
A pile of login tabs or windows waiting for me
Fixed in the version after 0.1.0. A failed sign-in used to be retried
twice a second from the moment the token expired, and in
Default browser mode each attempt opened a tab, so a
machine left alone overnight collected one every few minutes. Frost now
- waits for you after a sign-in nobody completed, and only retries by
- itself for failures that never put anything on your screen.
+ opens a login page only when somebody is at the machine to use it: the
+ sign-ins it runs while you are away are held silently, so trying again —
+ which it now does continuously — costs you nothing on screen.
+
+
+
An overnight refresh left expired credentials and a notification
+
+ Also fixed in the version after 0.1.0. Frost used to stop trying after a
+ sign-in nobody completed, so a refresh that came due at 3am was found in
+ the morning as a notification pointing at a device code that had died ten
+ minutes after it was issued — with Auto-open browser set,
+ which is precisely the setting that asks Frost not to leave it to you. A
+ dead code is now replaced immediately, for as long as it takes, so the
+ credentials come back on their own where they can and the first thing you
+ see when you sit down is a live login page where they cannot.
An AWS error naming the client or the endpoint
diff --git a/src/aws-sso.ts b/src/aws-sso.ts
index 5479de4..20374bb 100644
--- a/src/aws-sso.ts
+++ b/src/aws-sso.ts
@@ -1,4 +1,10 @@
-import { app, BrowserWindow, Notification, shell } from "electron";
+import {
+ app,
+ BrowserWindow,
+ Notification,
+ powerMonitor,
+ shell,
+} from "electron";
import log from "electron-log/main";
import delay from "delay";
import moment from "moment";
@@ -36,16 +42,46 @@ import {
import { describeError } from "./logging.js";
import { decryptSecret, encryptSecret } from "./secrets.js";
import {
+ AWAY_IDLE_SEC,
+ errorRetryDelayMs,
LoginAbortedError,
+ loginRetryAction,
+ loginWasAbandoned,
+ nextLoginAttemptDelayMs,
nextRefreshDelayMs,
- retryDelayMsAfterError,
+ PRESENCE_CHECK_INTERVAL_MS,
+ wasCancelledByUser,
} from "./schedule.js";
+/**
+ * How much of the device code's life has to be left for a login held for an
+ * absent user to be worth showing them when they arrive. Below this the page
+ * would expire while they were reading it; the attempt that replaces it starts
+ * immediately and opens a fresh one.
+ */
+const HANDOVER_MIN_CODE_LIFE_MS = 60 * 1000;
+
let timeoutId: NodeJS.Timeout | undefined;
let nextRefreshAt: number | null = null;
let consecutiveFailures = 0;
let pendingAuthResolve: (() => void) | null = null;
+/**
+ * The login that replaces one nobody finished, and the earliest it may start.
+ * Null when no login is outstanding.
+ */
+let loginRetry: { nextAttemptAtMs: number } | null = null;
+let loginRetryTimer: NodeJS.Timeout | undefined;
+
+/**
+ * Shows the login page the running attempt is holding for an absent user, or
+ * null when no attempt is holding one. It is what makes a login Frost is sitting
+ * on reachable: the user who asks for a refresh — hotkey, tray, dashboard — gets
+ * that live page instead of being told a refresh is already in progress, and the
+ * tray can say what the run is really waiting for.
+ */
+let showParkedLogin: (() => void) | null = null;
+
export function hasPendingAuth(): boolean {
return pendingAuthResolve !== null;
}
@@ -97,8 +133,8 @@ export function setNextTokenRefresh(delayMs?: number) {
}
/**
- * Stop refreshing until the user asks for one. The tray reads
- * getNextRefreshAt(), so "waiting for you" is visible rather than silent.
+ * Stop the ordinary expiry schedule. The tray reads getNextRefreshAt(), so the
+ * state this leaves behind is visible rather than silent.
*/
export function cancelTokenRefresh() {
if (timeoutId) {
@@ -114,6 +150,124 @@ export function getNextRefreshAt(): number | null {
return nextRefreshAt;
}
+/** What the tray should say about a login nobody has finished. */
+export type LoginRetryStatus = "none" | "replacing" | "waiting-for-user";
+
+/**
+ * Whether a login nobody finished is being replaced, and whether that
+ * replacement is one only the user can start. The tray says which: "Sign-in
+ * needed" on its own read as Frost having given up, which is what it used to do.
+ */
+export function getLoginRetryStatus(): LoginRetryStatus {
+ // A run holding a login for whoever comes back is waiting for them, not
+ // merely "Refreshing…".
+ if (showParkedLogin) return "waiting-for-user";
+ if (!loginRetry) return "none";
+ return canRefreshUnattended() || isUserPresent()
+ ? "replacing"
+ : "waiting-for-user";
+}
+
+/**
+ * Whether a refresh can get all the way through with nobody at the machine.
+ * Notification mode waits for the hotkey by design, and without automatic
+ * approval the login page is the user's to work through, so in both cases an
+ * unattended attempt could only ask AWS for a device code it cannot redeem.
+ */
+function canRefreshUnattended(): boolean {
+ const behavior =
+ (config.get("behaviorConfig") as BehaviorConfig | undefined) ||
+ DEFAULT_BEHAVIOR;
+ return behavior.autoApprove !== false && behavior.refreshMode !== "notify";
+}
+
+/**
+ * Whether somebody is at the machine, as far as the platform will say. Read
+ * whenever it matters rather than once per run: a ten-minute login can start
+ * with nobody there and end with the user watching it.
+ *
+ * getSystemIdleTime() is unimplemented on some Linux sessions, where it either
+ * throws or reports 0. Both read as "here", which keeps the plain behaviour —
+ * Frost opens the login page and lets the user find it — rather than waiting
+ * for a signal that is never coming.
+ */
+function isUserPresent(): boolean {
+ try {
+ const idleSec = powerMonitor.getSystemIdleTime();
+ log.debug("[isUserPresent] System idle for %ss", idleSec);
+ return idleSec < AWAY_IDLE_SEC;
+ } catch (err) {
+ log.warn(
+ "[isUserPresent] Could not read the idle time: %s",
+ describeError(err)
+ );
+ return true;
+ }
+}
+
+/**
+ * Replace a login nobody finished, without pausing first.
+ *
+ * Continuous refreshing is the whole premise: a device code AWS has expired is
+ * replaced by a live one, for as long as it takes, so credentials are current
+ * whether or not anyone is at the machine. Frost used to stop dead here and wait
+ * to be asked (#83), which is why an overnight refresh was found in the morning
+ * as a notification about a code that had expired in the night.
+ *
+ * `delayMs` is therefore 0 in every case that matters — the attempt it replaces
+ * has just spent the device code's ten-minute life — and the timer doubles as
+ * the presence check for the one login Frost cannot attempt alone.
+ */
+function scheduleLoginRetry(delayMs: number) {
+ cancelLoginRetry();
+ loginRetry = { nextAttemptAtMs: Date.now() + delayMs };
+ armLoginRetryTimer(delayMs);
+ log.info("[scheduleLoginRetry] Next login attempt in %sms", delayMs);
+}
+
+function armLoginRetryTimer(delayMs: number) {
+ if (loginRetryTimer) clearTimeout(loginRetryTimer);
+ loginRetryTimer = setTimeout(onLoginRetryDue, Math.max(delayMs, 0));
+}
+
+function cancelLoginRetry() {
+ if (loginRetryTimer) {
+ clearTimeout(loginRetryTimer);
+ loginRetryTimer = undefined;
+ }
+ loginRetry = null;
+}
+
+function onLoginRetryDue() {
+ if (!loginRetry) return;
+ loginRetryTimer = undefined;
+
+ // A run is already going — the manual refresh the user just asked for, say.
+ // It clears this retry itself once it commits.
+ if (config.get("isWorking")) {
+ armLoginRetryTimer(PRESENCE_CHECK_INTERVAL_MS);
+ return;
+ }
+
+ const action = loginRetryAction({
+ nowMs: Date.now(),
+ nextAttemptAtMs: loginRetry.nextAttemptAtMs,
+ userPresent: isUserPresent(),
+ canRefreshUnattended: canRefreshUnattended(),
+ });
+ if (action === "wait") {
+ // Nothing to do but watch for the user. Starting a run to discover that
+ // again would only record a failure every half minute.
+ armLoginRetryTimer(PRESENCE_CHECK_INTERVAL_MS);
+ return;
+ }
+
+ log.info("[onLoginRetryDue] Starting the replacement login");
+ // Not cancelled here: refresh() does that once it commits to a run, so a
+ // run that cannot start yet leaves the retry armed.
+ refresh();
+}
+
export async function refresh() {
log.info("[refresh] Refreshing credentials");
@@ -122,6 +276,9 @@ export async function refresh() {
if (!userConfig) {
log.warn("[refresh] Missing user config, cannot refresh credentials");
+ // Nothing can be signed in to, so stop replacing the login rather than
+ // leaving the tray claiming Frost is about to try again.
+ cancelLoginRetry();
return;
}
@@ -129,10 +286,26 @@ export async function refresh() {
// stranding the first run as "in-progress" forever and writing its
// remaining steps onto the wrong run.
if (config.get("isWorking")) {
+ // Unless the run in progress is holding a login for an absent user: they
+ // are evidently back, and the page waiting for them is better than
+ // "already in progress" and nothing on screen.
+ if (showParkedLogin) {
+ log.info("[refresh] Showing the login this run is holding");
+ showParkedLogin();
+ return;
+ }
log.warn("[refresh] A refresh is already in progress, skipping");
return;
}
+ // Committed to a run now: whatever a pending login was waiting for, this run
+ // supersedes it, and it arms the next one if it fails.
+ cancelLoginRetry();
+
+ // When the attempt started, not when it failed: what replaces it is due
+ // immediately, and MIN_LOGIN_CYCLE_MS is measured from here.
+ const attemptStartedAtMs = Date.now();
+
// The run id is what ties these lines to the entry the user is looking at
// in the Activity panel when they send a log in.
const { runId } = startRun();
@@ -173,7 +346,7 @@ export async function refresh() {
}
config.set("lastError", described);
completeRun("error", described);
- scheduleAfterFailure(err);
+ scheduleAfterFailure(err, attemptStartedAtMs);
} finally {
config.set("isWorking", false);
updateTrayIcon();
@@ -197,7 +370,7 @@ function hasValidToken(): boolean {
* in MIN_REFRESH_DELAY_MS and start a whole new login every half second. These
* three outcomes want three different answers instead.
*/
-function scheduleAfterFailure(err: unknown) {
+function scheduleAfterFailure(err: unknown, attemptStartedAtMs: number) {
// The token step succeeded and something afterwards (profiles, EKS) did
// not. The credentials are good, so stay on the ordinary expiry schedule:
// an error retry here would re-run getNewToken() and put a login page on
@@ -208,19 +381,37 @@ function scheduleAfterFailure(err: unknown) {
return;
}
- const retryDelayMs = retryDelayMsAfterError(err, consecutiveFailures + 1);
+ // The user ended the login themselves — closed the window, refused the
+ // sign-in at the identity provider. That is "not now" from somebody sitting
+ // right there, so take them at their word; the next login is the one they
+ // ask for. It is the only ending that is not replaced.
+ if (wasCancelledByUser(err)) {
+ consecutiveFailures = 0;
+ cancelTokenRefresh();
+ log.warn(
+ "[refresh] The user ended the login, waiting for a manual refresh"
+ );
+ return;
+ }
- // Nobody completed the login. Another attempt would just open another
- // login page for nobody to complete.
- if (retryDelayMs === undefined) {
+ // Nobody finished the login: it timed out, the device code expired, the page
+ // wanted a password with nobody there to type it. Start the next one at
+ // once. The device code's own ten-minute life is what paces this — Frost
+ // adds no delay of its own, because a gap here is a gap in the credentials.
+ if (loginWasAbandoned(err)) {
+ // Reaching a login page at all means the AWS calls worked, so an earlier
+ // error streak is stale.
consecutiveFailures = 0;
cancelTokenRefresh();
+ const delayMs = nextLoginAttemptDelayMs({
+ attemptStartedAtMs,
+ nowMs: Date.now(),
+ });
log.warn(
- "[refresh] Login was not completed, waiting for a manual refresh"
+ "[refresh] Login not completed, replacing it in %sms",
+ delayMs
);
- if (!(err instanceof LoginAbortedError) || !err.cancelledByUser) {
- notifyLoginNeeded();
- }
+ scheduleLoginRetry(delayMs);
return;
}
@@ -228,6 +419,7 @@ function scheduleAfterFailure(err: unknown) {
// registering the client. Nothing is on screen to pile up, and it may well
// fix itself, so back off and retry.
consecutiveFailures += 1;
+ const retryDelayMs = errorRetryDelayMs(consecutiveFailures);
log.info(
"[refresh] Failure %s in a row, retrying in %sms",
consecutiveFailures,
@@ -236,30 +428,27 @@ function scheduleAfterFailure(err: unknown) {
setNextTokenRefresh(retryDelayMs);
}
-/**
- * Say once that Frost has stopped trying. Not retrying an abandoned login is
- * what keeps the browser clean; without this it would also be silent, and the
- * user would come back to credentials that expired hours ago with nothing
- * working on it.
- */
-function notifyLoginNeeded() {
- const behavior =
- (config.get("behaviorConfig") as BehaviorConfig | undefined) ||
- DEFAULT_BEHAVIOR;
- const note = new Notification({
- title: "Frost — Sign-in Needed",
- body: `The AWS login was not completed. Press ${formatHotkey(
- behavior.refreshHotkey
- )} or use the tray to try again.`,
- });
- note.on("click", () => refresh());
- note.show();
-}
-
async function getNewToken(
userConfig: UserConfig
): Promise
{
config.set("lastError", null);
+
+ const behavior =
+ (config.get("behaviorConfig") as BehaviorConfig | undefined) ||
+ DEFAULT_BEHAVIOR;
+ const useBrowser = behavior.loginMethod === "default_browser";
+ const silent = behavior.autoApprove !== false;
+
+ // Nothing below can get through without the user: either they asked to be
+ // notified and press the hotkey first, or automatic approval is off and the
+ // login page is theirs to work through. Stop before asking AWS for a device
+ // code nobody can redeem; the retry watches for them and starts then.
+ if (!canRefreshUnattended() && !isUserPresent()) {
+ throw new LoginAbortedError(
+ "Nobody is at the machine, and this login needs the user"
+ );
+ }
+
const client = await getSsoClient(userConfig);
const ssooidc = new SSOOIDCClient({ region: userConfig.region });
@@ -287,10 +476,6 @@ async function getNewToken(
const tokenExpires = moment().add(expiresInSec, "seconds");
let pollIntervalMs = (startAuth.interval ?? 5) * 1000;
- const behavior =
- (config.get("behaviorConfig") as BehaviorConfig | undefined) ||
- DEFAULT_BEHAVIOR;
-
if (behavior.refreshMode === "notify") {
log.info("[getNewToken] Notify mode: showing notification");
const note = new Notification({
@@ -309,12 +494,18 @@ async function getNewToken(
throw new Error("Missing verification URL from device authorization");
}
- const useBrowser = behavior.loginMethod === "default_browser";
- const silent = behavior.autoApprove !== false;
-
- // In default-browser mode there is no window to watch, so windowOpen stays
- // true and the poll loop runs until the device code expires.
- let windowOpen = true;
+ /**
+ * Why this attempt is over, checked by the poll loop. Undefined for as long
+ * as the login is still worth waiting on — which, in default-browser mode,
+ * is until the device code expires: there is no window to watch there.
+ */
+ let abort: LoginAbortedError | undefined;
+ /**
+ * Set when the page needs the user and nobody is at the machine: what it
+ * would have been shown for, held until somebody turns up (see the poll
+ * loop). The attempt stays alive and hidden in the meantime.
+ */
+ let parkedReason: string | undefined;
let window: BrowserWindow | undefined;
let closingForBrowser = false;
let handedOver = false;
@@ -356,7 +547,10 @@ async function getNewToken(
* the default browser, presumably because that is where their passkeys and
* saved passwords live — that browser, with the silent attempt dropped.
*/
- const handOverToUser = (reason: string) => {
+ const showToUser = (reason: string) => {
+ parkedReason = undefined;
+ showParkedLogin = null;
+
if (!useBrowser) {
showLoginWindow(reason);
return;
@@ -385,6 +579,34 @@ async function getNewToken(
);
};
+ /**
+ * What the window's drivers call when the page stops being something Frost
+ * can get through on its own.
+ *
+ * With nobody at the machine there is nothing to hand it to: a window shown
+ * now, or a tab opened in a browser nobody is looking at, is a login page
+ * that expires unseen — an overnight refresh found as a dead page is the
+ * whole complaint. So the attempt is held instead, hidden and still being
+ * driven, which also leaves room for a slow identity provider to come
+ * through on its own. It is shown the moment somebody is there: the poll loop
+ * below checks, and so does a refresh the user asks for.
+ */
+ const handOverToUser = (reason: string) => {
+ if (!isUserPresent()) {
+ if (parkedReason === undefined) {
+ log.info(
+ "[getNewToken] Holding the login for whoever comes back: %s",
+ reason
+ );
+ }
+ parkedReason = reason;
+ showParkedLogin = () => showToUser(reason);
+ return;
+ }
+
+ showToUser(reason);
+ };
+
// Opening the login page happens inside the try: each attempt starts its
// own device authorization, so a window left behind by a throw would sit
// there pointing at a code nothing polls any more.
@@ -448,7 +670,9 @@ async function getNewToken(
// user saying "not now".
if (closingForBrowser) return;
log.warn("[getNewToken] Login window closed");
- windowOpen = false;
+ abort ??= new LoginAbortedError("Login window closed", {
+ cancelledByUser: true,
+ });
});
window.loadURL(verificationUrl);
@@ -500,25 +724,42 @@ async function getNewToken(
}
}
- // Closing the window means "I'm not logging in now". Give up here
- // rather than waiting for a non-pending token error, which may
- // never come — the run would then hold `isWorking` (and block every
- // new refresh) until the device code expires.
+ // The page is waiting for somebody and somebody is now here.
+ // Hand them the page this attempt already loaded rather than the
+ // one after it — but not a device code with seconds left, which
+ // would die under their hands; the replacement is immediate, so
+ // the next attempt shows them a fresh page instead.
+ if (parkedReason !== undefined && isUserPresent()) {
+ const codeLifeLeftMs = tokenExpires.diff(moment());
+ if (codeLifeLeftMs > HANDOVER_MIN_CODE_LIFE_MS) {
+ log.info(
+ "[getNewToken] The user is back, handing over the login"
+ );
+ showToUser(parkedReason);
+ }
+ }
+
+ // Closing the window means "I'm not logging in now", and an
+ // unattended attempt that turns out to need the user has nothing
+ // left to wait for. Give up here rather than waiting for a
+ // non-pending token error, which may never come — the run would
+ // then hold `isWorking` (and block every new refresh) until the
+ // device code expires.
//
// This has to come *after* the poll above, not before it. AWS tells
// the user to close the window as soon as they approve, so between
// the approval and the next poll the window is usually already
// gone — and checking first threw away a token that was waiting to
// be collected.
- if (!windowOpen) {
- log.warn("[getNewToken] User closed login window, aborting");
- throw new LoginAbortedError("Login window closed", {
- cancelledByUser: true,
- });
+ if (abort) {
+ log.warn("[getNewToken] Aborting: %s", abort.message);
+ throw abort;
}
}
throw new LoginAbortedError("Login timed out");
} finally {
+ // Nothing is holding a login any more, whatever happened to this one.
+ showParkedLogin = null;
// destroy(), not close(): cleanup must not depend on the remote page
// agreeing to unload.
if (window && !window.isDestroyed()) {
diff --git a/src/main.ts b/src/main.ts
index a93d866..b1d313d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -41,7 +41,10 @@ function registerHotkey(hotkey: string) {
if (hasPendingAuth()) {
log.info("[hotkey] Triggering pending auth");
triggerPendingAuth();
- } else if (!config.get("isWorking")) {
+ } else {
+ // No isWorking guard: refresh() skips a run that is already
+ // going, and shows the login page if that run is holding one for
+ // a user who has just come back.
log.info("[hotkey] Triggering refresh");
refresh();
}
diff --git a/src/schedule.ts b/src/schedule.ts
index b737c61..dc415b6 100644
--- a/src/schedule.ts
+++ b/src/schedule.ts
@@ -6,6 +6,36 @@ export const ERROR_RETRY_DELAY_MS = 60 * 1000;
/** Ceiling for that doubling, so a long outage settles at half-hourly. */
export const MAX_ERROR_RETRY_DELAY_MS = 30 * 60 * 1000;
+/**
+ * The shortest a login attempt may hold the slot before its replacement starts.
+ *
+ * Not a backoff: it never grows, and in practice nothing waits for it. AWS's
+ * device code lives about ten minutes, so an attempt nobody finishes takes that
+ * long to die and the next one begins the moment it does — which is what makes
+ * refreshing continuous rather than something that gives up and waits to be
+ * asked. This is only a floor against a login that fails the instant it starts
+ * (a device authorization AWS rejects outright), which would otherwise spin new
+ * attempts as fast as AWS could refuse them.
+ */
+export const MIN_LOGIN_CYCLE_MS = 30 * 1000;
+
+/**
+ * How often Frost looks for the user while holding a sign-in that cannot go
+ * anywhere without them — notification mode, or automatic approval turned off.
+ * Those cannot be attempted unattended at all, so there is nothing to do but
+ * wait for somebody and start the moment they are there.
+ */
+export const PRESENCE_CHECK_INTERVAL_MS = 30 * 1000;
+
+/**
+ * How much input idleness means nobody is at the machine. Long enough that
+ * reading a page without touching anything still counts as being here; short
+ * enough that a refresh coming due after the user has gone home is treated as
+ * unattended. Getting it wrong costs little in either direction: presence is
+ * re-read as the login runs, so "away" becomes "here" within a poll.
+ */
+export const AWAY_IDLE_SEC = 5 * 60;
+
/**
* A login run that ended because nobody completed it: the window was closed, the
* device code expired, or it timed out waiting for the user.
@@ -13,8 +43,9 @@ export const MAX_ERROR_RETRY_DELAY_MS = 30 * 60 * 1000;
export class LoginAbortedError extends Error {
/**
* True when the user ended it themselves — closed the window, refused the
- * sign-in at the identity provider. They already know it did not happen, so
- * Frost stays quiet; the passive endings are the ones worth a word.
+ * sign-in at the identity provider. That is "not now" from somebody sitting
+ * right there, so Frost stays quiet and stops; it is the one ending that is
+ * not replaced by another attempt.
*/
readonly cancelledByUser: boolean;
@@ -41,27 +72,47 @@ export function nextRefreshDelayMs(
return Math.max(expiresAtMs - nowMs, MIN_REFRESH_DELAY_MS);
}
+/** The user ending the login themselves, which is the one ending Frost obeys. */
+export function wasCancelledByUser(err: unknown): boolean {
+ return err instanceof LoginAbortedError && err.cancelledByUser;
+}
+
/**
- * Delay before retrying a failed refresh, or undefined to not retry automatically.
- *
- * An aborted login means no human finished the login page, so an automatic retry
- * opens another login page nobody is there to finish. Scheduling that retry off the
- * stale `expiresAt` made the delay collapse to MIN_REFRESH_DELAY_MS, which is how one
- * unattended login became dozens of browser tabs overnight. Those wait for the user to
- * refresh (tray, dashboard, or the hotkey) instead.
+ * A login that ended because nobody finished it, rather than because the user
+ * said no. Nothing is wrong with the configuration, so the answer is another
+ * attempt — immediately.
+ */
+export function loginWasAbandoned(err: unknown): boolean {
+ return err instanceof LoginAbortedError && !err.cancelledByUser;
+}
+
+/**
+ * When to start the login that replaces one nobody finished: now, unless the
+ * attempt it replaces was short enough for MIN_LOGIN_CYCLE_MS to still apply.
*
- * Everything else backs off as failures repeat. A cause that is not going to fix
- * itself — a start URL in the wrong region, an SSO instance that has been deleted —
- * otherwise asks AWS the same question every minute forever, silently.
+ * Never derived from the stored token expiry, which a failed run leaves in the
+ * past: that collapses to MIN_REFRESH_DELAY_MS and starts a whole new login
+ * every half second (#83).
*/
-export function retryDelayMsAfterError(
- err: unknown,
- consecutiveFailures = 1
-): number | undefined {
- if (err instanceof LoginAbortedError) {
- return undefined;
- }
+export function nextLoginAttemptDelayMs({
+ attemptStartedAtMs,
+ nowMs,
+}: {
+ attemptStartedAtMs: number;
+ nowMs: number;
+}): number {
+ return Math.max(attemptStartedAtMs + MIN_LOGIN_CYCLE_MS - nowMs, 0);
+}
+/**
+ * Delay before retrying a run that failed with no login page involved — no
+ * network, an AWS error registering the client. Nothing is on screen to pile up
+ * and it may well fix itself, so it backs off as failures repeat: a cause that
+ * is not going to fix itself (a start URL in the wrong region, an SSO instance
+ * that has been deleted) would otherwise ask AWS the same question every minute
+ * forever, silently.
+ */
+export function errorRetryDelayMs(consecutiveFailures = 1): number {
const doublings = Math.max(consecutiveFailures, 1) - 1;
// 2 ** doublings reaches Infinity long before this matters, and Math.min
// brings it back to the cap.
@@ -70,3 +121,36 @@ export function retryDelayMsAfterError(
MAX_ERROR_RETRY_DELAY_MS
);
}
+
+/** Whether the login that replaces an unfinished one can start yet. */
+export type LoginRetryAction = "go" | "wait";
+
+/**
+ * Decide whether to start the next login attempt.
+ *
+ * Nothing here paces Frost for the sake of pacing: a replacement goes as soon as
+ * the last attempt is out of the way. The one thing worth waiting for is the
+ * user, and only when Frost cannot make an attempt without them — in
+ * notification mode, or with automatic approval off, an unattended attempt would
+ * ask AWS for a device code, find nobody to give it to, and record a failed run
+ * for it, every time round.
+ */
+export function loginRetryAction({
+ nowMs,
+ nextAttemptAtMs,
+ userPresent,
+ canRefreshUnattended,
+}: {
+ nowMs: number;
+ nextAttemptAtMs: number;
+ userPresent: boolean;
+ canRefreshUnattended: boolean;
+}): LoginRetryAction {
+ if (nowMs < nextAttemptAtMs) {
+ return "wait";
+ }
+ if (!userPresent && !canRefreshUnattended) {
+ return "wait";
+ }
+ return "go";
+}
diff --git a/src/tray.ts b/src/tray.ts
index 40348de..413a59d 100644
--- a/src/tray.ts
+++ b/src/tray.ts
@@ -4,7 +4,7 @@ import { app, shell, Menu, Tray } from "electron";
import log from "electron-log/main";
import moment from "moment";
import { config } from "./config.js";
-import { getNextRefreshAt, refresh } from "./aws-sso.js";
+import { getLoginRetryStatus, getNextRefreshAt, refresh } from "./aws-sso.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -55,12 +55,24 @@ export function updateTrayIcon(onOpenDashboard?: () => void) {
const refreshItems = [] as Electron.MenuItemConstructorOptions[];
if (config.get("userConfig")) {
- // The scheduled time, not the token expiry: after an abandoned login
- // there is deliberately nothing scheduled (#83), and reading
- // `expiresAt` here would claim a refresh was due hours ago instead of
- // saying that Frost is waiting for the user.
+ // The scheduled time, not the token expiry: reading `expiresAt` after a
+ // failed login would claim a refresh was due hours ago (#83) instead of
+ // saying what Frost is actually waiting for.
const nextRefreshAt = getNextRefreshAt();
- if (nextRefreshAt !== null) {
+ const loginRetry = getLoginRetryStatus();
+ if (loginRetry !== "none") {
+ // A login is outstanding: the ordinary schedule is off, and a bare
+ // "Sign-in needed" would read as Frost having given up on it, which
+ // is what it used to do. Either another attempt is on its way, or
+ // Frost is holding one that only the user can get through.
+ refreshItems.push({
+ label:
+ loginRetry === "waiting-for-user"
+ ? "Sign-in needed — waiting for you"
+ : "Sign-in needed — trying again",
+ enabled: false,
+ });
+ } else if (nextRefreshAt !== null) {
refreshItems.push({
label: `Next refresh ${moment(nextRefreshAt).fromNow()}`,
enabled: false,