diff --git a/aesel/src/ac-session.mjs b/aesel/src/ac-session.mjs index a7679fad6e..96344b4de8 100644 --- a/aesel/src/ac-session.mjs +++ b/aesel/src/ac-session.mjs @@ -9,7 +9,7 @@ import {verifyAccount, requireHandle} from "./account-access.mjs"; import { EventEmitter } from "node:events"; import { createHash, randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; @@ -138,29 +138,58 @@ export class ACSession extends EventEmitter { } // A usable access token, refreshed a minute early through the refresh grant. + // Auth0 rotates refresh tokens, so the refresh runs under the + // `.lock` directory every ~/.ac-token refresher on this Mac takes + // (shared/ac-token.mjs, ac-login, Menu Band's ACSession.swift) and re-reads + // the file inside it: if another process renewed it meanwhile, use theirs. async token() { - const record = this.read(); + const stale = (record) => record.expires_at && this.now() > record.expires_at - 60_000; + let record = this.read(); if (!record?.access_token) throw new Error("not signed in — run /login"); - const stale = record.expires_at && this.now() > record.expires_at - 60_000; - if (!stale) return record.access_token; - if (!record.refresh_token) throw new Error("session expired — run /login"); - const response = await this.fetch(`https://${this.authDomain}/oauth/token`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - client_id: CLIENT_ID, - refresh_token: record.refresh_token, - }), + if (!stale(record)) return record.access_token; + return this.#locked(async () => { + record = this.read(); + if (!record?.access_token) throw new Error("not signed in — run /login"); + if (!stale(record)) return record.access_token; + if (!record.refresh_token) throw new Error("session expired — run /login"); + const response = await this.fetch(`https://${this.authDomain}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: record.refresh_token, + }), + }); + if (!response.ok) throw new Error(`session refresh failed (HTTP ${response.status}) — run /login`); + const next = await response.json(); + record.access_token = next.access_token; + if (next.refresh_token) record.refresh_token = next.refresh_token; + if (next.id_token) record.id_token = next.id_token; + record.expires_at = this.now() + (next.expires_in || 3600) * 1000; + this.#write(record); + return record.access_token; }); - if (!response.ok) throw new Error(`session refresh failed (HTTP ${response.status}) — run /login`); - const next = await response.json(); - record.access_token = next.access_token; - if (next.refresh_token) record.refresh_token = next.refresh_token; - if (next.id_token) record.id_token = next.id_token; - record.expires_at = this.now() + (next.expires_in || 3600) * 1000; - this.#write(record); - return record.access_token; + } + + // mkdir is atomic from Node and Swift alike; a lock older than 30 s + // belonged to a process that died. + async #locked(work) { + const lock = `${this.file}.lock`; + const deadline = Date.now() + 20_000; + for (;;) { + try { mkdirSync(lock); break; } + catch (error) { + if (error.code !== "EEXIST") throw error; + let age = 0; + try { age = Date.now() - statSync(lock).mtimeMs; } catch { continue; } + if (age > 30_000) { rmSync(lock, { recursive: true, force: true }); continue; } + if (Date.now() > deadline) throw new Error("another session refresh is holding ~/.ac-token.lock"); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + try { return await work(); } + finally { rmSync(lock, { recursive: true, force: true }); } } async requireAccount() { diff --git a/marketing/podcast/bin/daily-token.mjs b/marketing/podcast/bin/daily-token.mjs index de8920e138..179394c7da 100644 --- a/marketing/podcast/bin/daily-token.mjs +++ b/marketing/podcast/bin/daily-token.mjs @@ -26,6 +26,7 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; +import { freshSession } from "../../../shared/ac-token.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, ".."); @@ -68,11 +69,9 @@ const PRICE_XTZ = Number(process.env.DAILY_PRICE_XTZ || 3); const ROYALTIES = Number(process.env.DAILY_ROYALTIES_PERMILLE || 150); // HEN is per-mille const MIN_BALANCE_XTZ = 0.15; // a mint + a listing burn ~0.06 -// The AC session: AC_TOKEN if given, else ~/.ac-token (written once by -// tezos/ac-login.mjs and copied over), refreshed through its refresh token a -// minute early — the same grant easel uses — so the nightly run never lapses. -const AUTH0 = "hi.aesthetic.computer"; -const AUTH0_CLIENT_ID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt"; +// The AC session: AC_TOKEN if given, else this machine's own ~/.ac-token +// (from its own `ac-login`; never a copy from another machine, since Auth0 +// rotates refresh tokens), renewed a minute early under the shared lock. async function acToken() { if (process.env.AC_TOKEN) return process.env.AC_TOKEN; const file = resolve(process.env.HOME, ".ac-token"); @@ -80,18 +79,7 @@ async function acToken() { const record = JSON.parse(readFileSync(file, "utf8")); if (!record.expires_at || Date.now() < record.expires_at - 60_000) return record.access_token; if (!record.refresh_token) return null; - const r = await fetch(`https://${AUTH0}/oauth/token`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ grant_type: "refresh_token", client_id: AUTH0_CLIENT_ID, refresh_token: record.refresh_token }), - }); - if (!r.ok) throw new Error(`AC session refresh failed (${r.status}); rerun tezos/ac-login.mjs and copy ~/.ac-token over`); - const next = await r.json(); - record.access_token = next.access_token; - if (next.refresh_token) record.refresh_token = next.refresh_token; - record.expires_at = Date.now() + (next.expires_in || 3600) * 1000; - writeFileSync(file, JSON.stringify(record, null, 2), { mode: 0o600 }); - return record.access_token; + return (await freshSession({ file })).access_token; } const AC_TOKEN = await acToken(); diff --git a/plugins/whistlegraph/scripts/whistlegraph-mcp.mjs b/plugins/whistlegraph/scripts/whistlegraph-mcp.mjs index f9affcdee0..747bb63795 100644 --- a/plugins/whistlegraph/scripts/whistlegraph-mcp.mjs +++ b/plugins/whistlegraph/scripts/whistlegraph-mcp.mjs @@ -13,13 +13,12 @@ import { homedir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; import { promisify } from "node:util"; import * as readline from "node:readline"; +import { freshSession } from "../../../shared/ac-token.mjs"; const execFile = promisify(execFileCallback); const SITE_PREFIX = "system/public/whistlegraph.org/"; const STATE_ROOT = join(homedir(), ".cache", "whistlegraph-desk"); const TOKEN_FILE = join(homedir(), ".ac-token"); -const AUTH0_DOMAIN = "hi.aesthetic.computer"; -const AUTH0_CLIENT_ID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt"; const API = process.env.WHISTLEGRAPH_API || "https://whistlegraph.org/api/whistlegraph-admin"; const MAX_FILE_BYTES = 2_000_000; const MAX_DIFF_BYTES = 500_000; @@ -57,28 +56,10 @@ async function loadTokens() { } catch { throw new Error("Not signed in. Run `ac-login`, then try again."); } - const stale = tokens.expires_at && Date.now() > tokens.expires_at - 60_000; - if (stale) { + // Renewed under the lock every ~/.ac-token refresher on this Mac shares. + if (tokens.expires_at && Date.now() > tokens.expires_at - 60_000) { if (!tokens.refresh_token) throw new Error("Your AC session expired. Run `ac-login` again."); - const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/json", "User-Agent": UA }, - body: JSON.stringify({ - grant_type: "refresh_token", - client_id: AUTH0_CLIENT_ID, - refresh_token: tokens.refresh_token, - }), - signal: AbortSignal.timeout(20_000), - }); - if (!response.ok) throw new Error(`AC token refresh failed (HTTP ${response.status}). Run \`ac-login\`.`); - const next = await response.json(); - tokens = { - ...tokens, - ...next, - refresh_token: next.refresh_token || tokens.refresh_token, - expires_at: Date.now() + (next.expires_in || 3600) * 1000, - }; - await writeFile(TOKEN_FILE, `${JSON.stringify(tokens, null, 2)}\n`, { mode: 0o600 }); + tokens = await freshSession({ file: TOKEN_FILE }); } if (!tokens.access_token) throw new Error("AC session has no access token. Run `ac-login`."); return tokens; diff --git a/shared/ac-token.mjs b/shared/ac-token.mjs new file mode 100644 index 0000000000..f04b90eae1 --- /dev/null +++ b/shared/ac-token.mjs @@ -0,0 +1,70 @@ +// ac-token.mjs — the one way to renew ~/.ac-token. +// +// Auth0 rotates refresh tokens: each refresh spends the old one. Two +// processes on one Mac refreshing at once (ac-login, Menu Band, Aesel, an +// MCP) would spend it twice and sign that Mac out. So every refresher holds +// the `~/.ac-token.lock` directory while it refreshes, then re-reads the +// file: if someone else already renewed it, it keeps theirs. +// +// mkdir is the lock because it is atomic from Node and Swift alike +// (slab/menuband + shared/swift ACSession.swift take the same one). A lock +// older than 30 s belonged to a process that died and is taken over. +// +// Across machines there is nothing to lock: each Mac signs in with its own +// `ac-login` and gets its own refresh token. Never copy ~/.ac-token between +// machines — with rotation, the first refresh on one kills the other. + +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const AUTH_DOMAIN = "hi.aesthetic.computer"; +export const CLIENT_ID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt"; +export const TOKEN_FILE = join(homedir(), ".ac-token"); +const EARLY_MS = 60 * 1000; // Renew a minute before expiry. +const STALE_LOCK_MS = 30 * 1000; +const WAIT_MS = 20 * 1000; + +export async function withTokenLock(work, file = TOKEN_FILE) { + const lock = file + ".lock"; + const deadline = Date.now() + WAIT_MS; + for (;;) { + try { await mkdir(lock); break; } + catch (error) { + if (error.code !== "EEXIST") throw error; + const age = Date.now() - ((await stat(lock).catch(() => null))?.mtimeMs ?? Date.now()); + if (age > STALE_LOCK_MS) { await rm(lock, { recursive: true, force: true }); continue; } + if (Date.now() > deadline) throw new Error(`Another refresh is holding ${lock}`); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + try { return await work(); } + finally { await rm(lock, { recursive: true, force: true }); } +} + +// The session record with a usable access token, renewed through the refresh +// grant under the lock when it is within a minute of expiring (or `force`). +// Keeps every other field; writes in place so file watchers fire. +export async function freshSession({ file = TOKEN_FILE, force = false, fetch = globalThis.fetch } = {}) { + const usable = (record) => !force && record?.access_token && !(record.expires_at && Date.now() > record.expires_at - EARLY_MS); + const current = JSON.parse(await readFile(file, "utf8")); + if (usable(current)) return current; + return withTokenLock(async () => { + const record = JSON.parse(await readFile(file, "utf8")); + if (usable(record)) return record; // Renewed by another process while we waited. + if (!record.refresh_token) throw new Error("No refresh token; run `ac-login`"); + const response = await fetch(`https://${AUTH_DOMAIN}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: record.refresh_token }), + }); + if (!response.ok) throw new Error(`AC session refresh failed (${response.status}); run \`ac-login\``); + const next = await response.json(); + record.access_token = next.access_token; + if (next.refresh_token) record.refresh_token = next.refresh_token; + if (next.id_token) record.id_token = next.id_token; + record.expires_at = Date.now() + (next.expires_in || 3600) * 1000; + await writeFile(file, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); + return record; + }, file); +} diff --git a/shared/swift/ACSession.swift b/shared/swift/ACSession.swift index 26551e49cf..0750eaf191 100644 --- a/shared/swift/ACSession.swift +++ b/shared/swift/ACSession.swift @@ -75,6 +75,95 @@ final class ACSession { return t } + /// A usable access token, renewed through the refresh grant when it is + /// stale (a minute early), with no browser. Blocks while it talks to + /// Auth0 — call it off main. Writes the renewed token back to + /// ~/.ac-token (keeping every other field) and tells the other AC apps. + /// Same request as aesel/src/ac-session.mjs. + /// + /// Auth0 rotates refresh tokens, so the refresh runs under the + /// `~/.ac-token.lock` directory that every refresher on this Mac takes + /// (shared/ac-token.mjs, ac-login, Aesel) and re-reads the file inside + /// it: if another process renewed the token meanwhile, it uses theirs. + func freshToken() -> String? { + if let t = usableToken() { return t } + guard lockTokenFile() else { return token() } + defer { unlockTokenFile() } + if let t = usableToken() { return t } // Renewed while we waited. + guard let data = try? Data(contentsOf: Self.tokenURL), + var record = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let refresh = record["refresh_token"] as? String, !refresh.isEmpty, + let url = URL(string: "https://hi.aesthetic.computer/oauth/token") + else { return token() } + var request = URLRequest(url: url, timeoutInterval: 20) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: [ + "grant_type": "refresh_token", + "client_id": "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt", + "refresh_token": refresh, + ]) + var reply: [String: Any]? + let done = DispatchSemaphore(value: 0) + URLSession.shared.dataTask(with: request) { body, response, _ in + if (response as? HTTPURLResponse)?.statusCode == 200, let body { + reply = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + } + done.signal() + }.resume() + done.wait() + guard let reply, let access = reply["access_token"] as? String else { + NSLog("[ac-session] refresh failed — run ac-login") + return token() + } + record["access_token"] = access + if let r = reply["refresh_token"] as? String { record["refresh_token"] = r } + if let i = reply["id_token"] as? String { record["id_token"] = i } + let seconds = (reply["expires_in"] as? Double) ?? 3600 + record["expires_at"] = (Date().timeIntervalSince1970 + seconds) * 1000 + if let out = try? JSONSerialization.data(withJSONObject: record, options: [.prettyPrinted]) { + try? out.write(to: Self.tokenURL) + broadcastChanged() + } + return access + } + + /// The access token if it has more than a minute left. + private func usableToken() -> String? { + guard let t = token(), let exp = load()?.expires_at, + exp - 60_000 > Date().timeIntervalSince1970 * 1000 else { return nil } + return t + } + + private static var lockURL: URL { tokenURL.appendingPathExtension("lock") } + + /// mkdir is atomic from Swift and Node alike. A lock older than 30 s + /// belonged to a process that died and is taken over; gives up after 20 s. + private func lockTokenFile() -> Bool { + let fm = FileManager.default + let deadline = Date().addingTimeInterval(20) + while true { + do { + try fm.createDirectory(at: Self.lockURL, withIntermediateDirectories: false) + return true + } catch { + let made = (try? fm.attributesOfItem(atPath: Self.lockURL.path))?[.modificationDate] as? Date + if let made, Date().timeIntervalSince(made) > 30 { + try? fm.removeItem(at: Self.lockURL) + continue + } + if made == nil, !fm.fileExists(atPath: Self.lockURL.path) { continue } + if Date() > deadline { + NSLog("[ac-session] ~/.ac-token.lock held too long — skipping refresh") + return false + } + Thread.sleep(forTimeInterval: 0.2) + } + } + } + + private func unlockTokenFile() { try? FileManager.default.removeItem(at: Self.lockURL) } + /// The AC @handle (without the leading "@"), or nil. Safe to display. var handle: String? { load()?.user?.handle } diff --git a/slab/menuband/Sources/MenuBand/ACSession.swift b/slab/menuband/Sources/MenuBand/ACSession.swift new file mode 100644 index 0000000000..0750eaf191 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/ACSession.swift @@ -0,0 +1,295 @@ +// ACSession.swift — canonical shared AC session reader for the macOS app suite. +// +// SOURCE OF TRUTH: shared/swift/ACSession.swift. The standalone SwiftPM apps +// (date-wizard, wave-wizard, clip-wizard, juke-wizard, slab menubar, menuband) +// each keep a COPY of this file in their own Sources/ — there is no shared SPM +// target across them. When you change this file, re-copy it into each consumer +// (a one-liner: `cp shared/swift/ACSession.swift /Sources//`). +// +// One sign-in serves the whole suite. The AC stack (`ac-login` CLI, ac-os, the +// AC Electron app) writes a single session token at ~/.ac-token: +// +// { "access_token": "", "refresh_token": "...", "id_token": "...", +// "expires_at": , +// "user": { "handle": "jeffrey", "email": "...", "sub": "auth0|…", +// "name": "...", "picture": "..." } } +// +// Use `access_token` as the Authorization Bearer for aesthetic.computer APIs. +// For display use `handle` (show "@handle"); never surface email/name (PII). +// +// "Broadcast" = the shared file. ACSession.shared.startWatching { … } fires the +// instant ~/.ac-token changes (atomic-write aware — it watches the parent +// directory), so a sign-in/out in ANY app (or the Electron tray) updates every +// running app live, with no restart and no polling. A best-effort +// NSDistributedNotification ("computer.aesthetic.session.changed") is also +// posted/observed for instant Swift↔Swift refresh. +import Foundation + +final class ACSession { + static let shared = ACSession() + + static let didChangeNotification = + Notification.Name("computer.aesthetic.session.changed") + + // ~/.ac-token + static var tokenURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".ac-token") + } + + // ── on-disk shape ──────────────────────────────────────────────── + private struct User: Codable { + var handle: String? + var email: String? + var sub: String? + var name: String? + var picture: String? + } + private struct TokenFile: Codable { + var access_token: String? + var refresh_token: String? + var id_token: String? + var expires_at: Double? // ms-epoch + var user: User? + } + + private func load() -> TokenFile? { + guard let data = try? Data(contentsOf: Self.tokenURL) else { return nil } + return try? JSONDecoder().decode(TokenFile.self, from: data) + } + + // ── public read surface ────────────────────────────────────────── + + enum State { case signedIn, expired, signedOut } + + var state: State { + guard let tf = load(), let t = tf.access_token, !t.isEmpty else { return .signedOut } + if let exp = tf.expires_at, exp <= Date().timeIntervalSince1970 * 1000 { return .expired } + return .signedIn + } + + /// The current valid access token, or nil if missing/unparseable/expired. + func token() -> String? { + guard let tf = load(), let t = tf.access_token, !t.isEmpty else { return nil } + if let exp = tf.expires_at, exp <= Date().timeIntervalSince1970 * 1000 { return nil } + return t + } + + /// A usable access token, renewed through the refresh grant when it is + /// stale (a minute early), with no browser. Blocks while it talks to + /// Auth0 — call it off main. Writes the renewed token back to + /// ~/.ac-token (keeping every other field) and tells the other AC apps. + /// Same request as aesel/src/ac-session.mjs. + /// + /// Auth0 rotates refresh tokens, so the refresh runs under the + /// `~/.ac-token.lock` directory that every refresher on this Mac takes + /// (shared/ac-token.mjs, ac-login, Aesel) and re-reads the file inside + /// it: if another process renewed the token meanwhile, it uses theirs. + func freshToken() -> String? { + if let t = usableToken() { return t } + guard lockTokenFile() else { return token() } + defer { unlockTokenFile() } + if let t = usableToken() { return t } // Renewed while we waited. + guard let data = try? Data(contentsOf: Self.tokenURL), + var record = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let refresh = record["refresh_token"] as? String, !refresh.isEmpty, + let url = URL(string: "https://hi.aesthetic.computer/oauth/token") + else { return token() } + var request = URLRequest(url: url, timeoutInterval: 20) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: [ + "grant_type": "refresh_token", + "client_id": "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt", + "refresh_token": refresh, + ]) + var reply: [String: Any]? + let done = DispatchSemaphore(value: 0) + URLSession.shared.dataTask(with: request) { body, response, _ in + if (response as? HTTPURLResponse)?.statusCode == 200, let body { + reply = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + } + done.signal() + }.resume() + done.wait() + guard let reply, let access = reply["access_token"] as? String else { + NSLog("[ac-session] refresh failed — run ac-login") + return token() + } + record["access_token"] = access + if let r = reply["refresh_token"] as? String { record["refresh_token"] = r } + if let i = reply["id_token"] as? String { record["id_token"] = i } + let seconds = (reply["expires_in"] as? Double) ?? 3600 + record["expires_at"] = (Date().timeIntervalSince1970 + seconds) * 1000 + if let out = try? JSONSerialization.data(withJSONObject: record, options: [.prettyPrinted]) { + try? out.write(to: Self.tokenURL) + broadcastChanged() + } + return access + } + + /// The access token if it has more than a minute left. + private func usableToken() -> String? { + guard let t = token(), let exp = load()?.expires_at, + exp - 60_000 > Date().timeIntervalSince1970 * 1000 else { return nil } + return t + } + + private static var lockURL: URL { tokenURL.appendingPathExtension("lock") } + + /// mkdir is atomic from Swift and Node alike. A lock older than 30 s + /// belonged to a process that died and is taken over; gives up after 20 s. + private func lockTokenFile() -> Bool { + let fm = FileManager.default + let deadline = Date().addingTimeInterval(20) + while true { + do { + try fm.createDirectory(at: Self.lockURL, withIntermediateDirectories: false) + return true + } catch { + let made = (try? fm.attributesOfItem(atPath: Self.lockURL.path))?[.modificationDate] as? Date + if let made, Date().timeIntervalSince(made) > 30 { + try? fm.removeItem(at: Self.lockURL) + continue + } + if made == nil, !fm.fileExists(atPath: Self.lockURL.path) { continue } + if Date() > deadline { + NSLog("[ac-session] ~/.ac-token.lock held too long — skipping refresh") + return false + } + Thread.sleep(forTimeInterval: 0.2) + } + } + } + + private func unlockTokenFile() { try? FileManager.default.removeItem(at: Self.lockURL) } + + /// The AC @handle (without the leading "@"), or nil. Safe to display. + var handle: String? { load()?.user?.handle } + + /// "@handle" for display, falling back to a neutral label (never email/PII). + var displayName: String? { handle.map { $0.hasPrefix("@") ? $0 : "@\($0)" } } + + /// Auth0 subject id (for API calls that key off the user). + var sub: String? { load()?.user?.sub } + + /// expires_at in ms-epoch, if known. + var expiresAt: Double? { load()?.expires_at } + + // ── live broadcast (file-watch + distributed notification) ─────── + // We watch BOTH the parent directory and the file itself, because writers + // differ: `ac-login` overwrites ~/.ac-token IN PLACE (fs.writeFile → same + // inode → a directory event does NOT fire, but the file's .write does), + // while `ac-login logout` / atomic replacers delete/rename the file (the + // file watch goes stale → only the directory event fires). The directory + // watch also re-arms the file watch when the token reappears. + private var dirSource: DispatchSourceFileSystemObject? + private var dirFD: Int32 = -1 + private var fileSource: DispatchSourceFileSystemObject? + private var fileFD: Int32 = -1 + private var observers: [UUID: () -> Void] = [:] + private var distributedObserver: NSObjectProtocol? + + /// Register a callback that fires (on the main queue) whenever the shared + /// session changes — sign-in, sign-out, refresh. Returns a token you can + /// pass to `stopWatching` (or ignore; everything is torn down on dealloc). + @discardableResult + func startWatching(_ onChange: @escaping () -> Void) -> UUID { + let id = UUID() + observers[id] = onChange + installDirectoryWatchIfNeeded() + installFileWatchIfNeeded() + installDistributedObserverIfNeeded() + return id + } + + func stopWatching(_ id: UUID) { observers[id] = nil } + + private func installDirectoryWatchIfNeeded() { + guard dirSource == nil else { return } + let dir = Self.tokenURL.deletingLastPathComponent() + let fd = open(dir.path, O_EVTONLY) + guard fd >= 0 else { return } + dirFD = fd + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, eventMask: [.write, .rename, .delete], queue: .main) + src.setEventHandler { [weak self] in + // A directory change may mean the token was (re)created/replaced — + // (re)arm the file watch, then report. + self?.installFileWatchIfNeeded() + self?.fireChanged() + } + src.setCancelHandler { [weak self] in + if let fd = self?.dirFD, fd >= 0 { close(fd); self?.dirFD = -1 } + } + src.resume() + dirSource = src + } + + private func installFileWatchIfNeeded() { + guard fileSource == nil else { return } + let fd = open(Self.tokenURL.path, O_EVTONLY) + guard fd >= 0 else { return } // file not present yet — dir watch will re-arm + fileFD = fd + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, eventMask: [.write, .extend, .rename, .delete, .revoke], queue: .main) + src.setEventHandler { [weak self] in + guard let self else { return } + let data = src.data + self.fireChanged() + // File was replaced/removed → this watch is stale; tear down so the + // directory watch can re-arm a fresh one. + if !data.intersection([.rename, .delete, .revoke]).isEmpty { src.cancel() } + } + src.setCancelHandler { [weak self] in + if let fd = self?.fileFD, fd >= 0 { close(fd); self?.fileFD = -1 } + self?.fileSource = nil + } + src.resume() + fileSource = src + } + + private func installDistributedObserverIfNeeded() { + guard distributedObserver == nil else { return } + distributedObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.didChangeNotification, object: nil, queue: .main + ) { [weak self] _ in self?.notifyObservers() } + } + + // Debounce burst of fs events; only notify on a real token-string change. + private var lastTokenSeen: String? + private var debounceItem: DispatchWorkItem? + private func fireChanged() { + debounceItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self else { return } + let now = self.load()?.access_token + if now != self.lastTokenSeen { + self.lastTokenSeen = now + self.notifyObservers() + } + } + debounceItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: item) + } + + private func notifyObservers() { for cb in observers.values { cb() } } + + /// Tell other AC apps the session changed (call after sign-in/out you drive). + func broadcastChanged() { + DistributedNotificationCenter.default().postNotificationName( + Self.didChangeNotification, object: nil, userInfo: nil, + deliverImmediately: true) + } + + // ── sign-in helper ─────────────────────────────────────────────── + /// Launch `ac-login` in Terminal so the user can sign in without leaving + /// the app. (Re)writes ~/.ac-token on success; the file-watch picks it up. + func runAcLogin() { + let script = "tell application \"Terminal\"\nactivate\ndo script \"ac-login\"\nend tell" + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + task.arguments = ["-e", script] + try? task.run() + } +} diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index b19eabb8b0..305da566eb 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -969,6 +969,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // would briefly reserve room for the deck and then snap back. KeyboardIconRenderer.tapeFeatureEnabled = UserDefaults.standard .bool(forKey: KeyboardIconRenderer.tapeFeatureDefaultsKey) +#if !MAC_APP_STORE + // Back takes up to the signed-in AC handle; drains anything a + // previous run left queued. + MenuBandCloud.shared.start() +#endif NotificationCenter.default.addObserver( self, selector: #selector(handleTapeFeatureToggled(_:)), @@ -4285,6 +4290,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } pendingTapeExports[id] = [completion] + let program = Int(menuBand.melodicProgram) + // A take only has a real tempo when something was keeping time: the + // polyrhythm trainer or the conductor engine. Free play sends none + // (notes.mid is stamped 120 regardless). + let bpm: Double? = polyrhythmTrainer.isActive ? Double(polyrhythmTrainer.bpm) + : engine.running ? engine.bpm : nil MenuBandTape.exportQueue.async { [self] in let started = ProcessInfo.processInfo.systemUptime var result: ExportedTape? @@ -4298,6 +4309,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } #endif result = ExportedTape(file: file, stems: take.stems, cover: take.cover) +#if !MAC_APP_STORE + // Every take is exported once (the recording→idle pre-warm), + // so this is the one place a take enters the cloud queue. + MenuBandCloud.shared.enqueue(takeID: id, recordedAt: take.date, + duration: take.duration, program: program, bpm: bpm, + mix: file, stems: take.stems, cover: take.cover) +#endif } NSLog("MenuBand: tape export finished in \(ProcessInfo.processInfo.systemUptime - started)s off main") let exported = result diff --git a/slab/menuband/Sources/MenuBand/MenuBandCloud.swift b/slab/menuband/Sources/MenuBand/MenuBandCloud.swift new file mode 100644 index 0000000000..fd2ef5769f --- /dev/null +++ b/slab/menuband/Sources/MenuBand/MenuBandCloud.swift @@ -0,0 +1,309 @@ +#if !MAC_APP_STORE +import AppKit +import Foundation + +/// Backs every tape take up to the signed-in Aesthetic Computer handle. +/// +/// Each export lands a copy of the take in a durable queue folder, then a +/// serial utility queue drains it against `/api/menuband-takes`: +/// +/// begin (takeId, file sizes) → presigned PUTs → PUT each file → commit +/// +/// A queue entry is deleted only after `commit` (or when the server says the +/// take is already committed), so a take recorded offline, signed out, or +/// with an expired session waits on disk until the next drain. Drains run on +/// launch, on any ~/.ac-token change, after each take, and on a backoff timer +/// after a failure. Nothing here touches main or the audio graph. +/// +/// Direct-download build only: the sandboxed App Store build can't read +/// ~/.ac-token. +/// +/// ~/Library/Application Support/MenuBand/cloud-queue// +/// manifest.json written last; a folder without one is ignored +/// mix.mp3 | mix.wav, tones.wav, percussion.wav, voice.wav, +/// notes.mid, mix.json, cover.png +final class MenuBandCloud { + static let shared = MenuBandCloud() + + /// UserDefaults flag, next to the tape-deck flags. Unset = on, so a + /// signed-in user backs up without having to find the switch. + static let enabledDefaultsKey = "MenuBandCloudBackupEnabled" + /// Posted on main when the queue or its status changes (Settings listens). + static let statusChanged = Notification.Name("MenuBandCloudStatusChanged") + + static var isEnabled: Bool { + get { UserDefaults.standard.object(forKey: enabledDefaultsKey) as? Bool ?? true } + set { + UserDefaults.standard.set(newValue, forKey: enabledDefaultsKey) + if newValue { shared.drainSoon() } + } + } + + /// Override with MENUBAND_TAKES_URL to point a dev build at a local lith. + private static let endpoint = URL(string: + ProcessInfo.processInfo.environment["MENUBAND_TAKES_URL"] + ?? "https://aesthetic.computer/api/menuband-takes")! + + private struct Manifest: Codable { + var takeId: String + var recordedAt: String + var machine: String + var duration: Double + var program: Int? + var bpm: Double? + var files: [String] + } + + private static let contentTypes: [String: String] = [ + "mix.mp3": "audio/mpeg", "mix.wav": "audio/wav", + "tones.wav": "audio/wav", "percussion.wav": "audio/wav", "voice.wav": "audio/wav", + "notes.mid": "audio/midi", "mix.json": "application/json", "cover.png": "image/png", + ] + + private let queue = DispatchQueue(label: "computer.aestheticcomputer.menuband.cloud", qos: .utility) + private let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 30 * 60 + config.waitsForConnectivity = false + return URLSession(configuration: config) + }() + + // Queue-confined state. + private var draining = false + private var backoff: TimeInterval = 30 + private var retryItem: DispatchWorkItem? + /// Takes already committed this run, so a re-export of an old take (the + /// export cache holds four) doesn't queue it a second time. + private var committed = Set() + + private init() {} + + private var root: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("MenuBand/cloud-queue", isDirectory: true) + } + + /// Call once on launch, on main. + func start() { + dispatchPrecondition(condition: .onQueue(.main)) + ACSession.shared.startWatching { [weak self] in + self?.postStatus() + self?.drainSoon() + } + drainSoon() + } + + /// Takes waiting on disk. Cheap; safe from any thread. + var pendingCount: Int { + let dirs = (try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil)) ?? [] + return dirs.filter { + FileManager.default.fileExists(atPath: $0.appendingPathComponent("manifest.json").path) + }.count + } + + // MARK: - Enqueue + + /// Copy a finished take into the queue and drain. Callable from any + /// thread; the copies run on the cloud queue. + func enqueue(takeID: UUID, recordedAt: Date, duration: Double, program: Int?, bpm: Double?, + mix: URL, stems: URL?, cover: NSImage?) { + queue.async { [self] in + let takeId = takeID.uuidString + guard !committed.contains(takeId) else { return } + let dir = root.appendingPathComponent(takeId, isDirectory: true) + let fm = FileManager.default + if fm.fileExists(atPath: dir.appendingPathComponent("manifest.json").path) { + drain() + return + } + do { + try? fm.removeItem(at: dir) // a half-written folder from a crash + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + var files: [String] = [] + func add(_ source: URL, as name: String) { + guard fm.fileExists(atPath: source.path) else { return } + do { + try fm.copyItem(at: source, to: dir.appendingPathComponent(name)) + files.append(name) + } catch { NSLog("MenuBand cloud: copy \(name) failed: \(error)") } + } + add(mix, as: mix.pathExtension.lowercased() == "mp3" ? "mix.mp3" : "mix.wav") + if let stems { + for name in ["tones.wav", "percussion.wav", "voice.wav", "notes.mid", "mix.json"] { + add(stems.appendingPathComponent(name), as: name) + } + } + if let cover, let png = Self.pngData(cover) { + if (try? png.write(to: dir.appendingPathComponent("cover.png"))) != nil { + files.append("cover.png") + } + } + let manifest = Manifest( + takeId: takeId, + recordedAt: ISO8601DateFormatter().string(from: recordedAt), + machine: Host.current().localizedName ?? ProcessInfo.processInfo.hostName, + duration: duration, program: program, bpm: bpm, files: files) + try JSONEncoder().encode(manifest) + .write(to: dir.appendingPathComponent("manifest.json"), options: .atomic) + NSLog("MenuBand cloud: queued \(takeId) (\(files.joined(separator: ", ")))") + } catch { + NSLog("MenuBand cloud: queue \(takeId) failed: \(error)") + try? fm.removeItem(at: dir) + return + } + postStatus() + drain() + } + } + + // MARK: - Drain + + func drainSoon() { queue.async { [self] in drain() } } + + /// Runs on `queue`. Serial: one take, one file at a time. + private func drain() { + guard !draining, Self.isEnabled else { return } + guard let token = ACSession.shared.freshToken() else { + if pendingCount > 0 { NSLog("MenuBand cloud: \(pendingCount) waiting — not signed in (run ac-login)") } + return + } + let entries = loadManifests() + guard !entries.isEmpty else { return } + draining = true + defer { draining = false; postStatus() } + retryItem?.cancel() + for (dir, manifest) in entries { + switch upload(dir: dir, manifest: manifest, token: token) { + case .done: + committed.insert(manifest.takeId) + try? FileManager.default.removeItem(at: dir) + backoff = 30 + NSLog("MenuBand cloud: backed up \(manifest.takeId)") + DispatchQueue.main.async { ReadyChime.shared.playBackedUp() } + case .unauthorized: + // Wait for a fresh ~/.ac-token; the session watcher re-drains. + NSLog("MenuBand cloud: session rejected — waiting for ac-login") + return + case .failed(let why): + NSLog("MenuBand cloud: \(manifest.takeId) failed (\(why)); retry in \(Int(backoff))s") + scheduleRetry() + return + } + } + } + + private func scheduleRetry() { + let item = DispatchWorkItem { [weak self] in self?.drain() } + retryItem = item + queue.asyncAfter(deadline: .now() + backoff, execute: item) + backoff = min(backoff * 2, 3600) + } + + private func loadManifests() -> [(URL, Manifest)] { + let dirs = (try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil)) ?? [] + return dirs.compactMap { dir -> (URL, Manifest)? in + guard let data = try? Data(contentsOf: dir.appendingPathComponent("manifest.json")), + let m = try? JSONDecoder().decode(Manifest.self, from: data) else { return nil } + return (dir, m) + }.sorted { $0.1.recordedAt < $1.1.recordedAt } + } + + private enum Outcome { case done, unauthorized, failed(String) } + + private func upload(dir: URL, manifest: Manifest, token: String) -> Outcome { + let fm = FileManager.default + let files: [[String: Any]] = manifest.files.compactMap { name in + let path = dir.appendingPathComponent(name).path + guard let size = (try? fm.attributesOfItem(atPath: path))?[.size] as? NSNumber else { return nil } + return ["name": name, "bytes": size.intValue] + } + var begin: [String: Any] = [ + "action": "begin", "takeId": manifest.takeId, "recordedAt": manifest.recordedAt, + "machine": manifest.machine, "duration": manifest.duration, "files": files, + ] + if let program = manifest.program { begin["program"] = program } + if let bpm = manifest.bpm { begin["bpm"] = bpm } + + let (status, body) = post(begin, token: token) + if status == 401 || status == 403 { return .unauthorized } + guard status == 200, let code = body["code"] as? String else { + return .failed("begin \(status) \(body["error"] ?? body["message"] ?? "")") + } + if body["committed"] as? Bool == true { return .done } + + for upload in body["uploads"] as? [[String: Any]] ?? [] { + guard let name = upload["name"] as? String, + let urlString = upload["url"] as? String, + let url = URL(string: urlString) else { return .failed("bad upload entry") } + var request = URLRequest(url: url) + request.httpMethod = upload["method"] as? String ?? "PUT" + for (key, value) in upload["headers"] as? [String: String] ?? [:] { + request.setValue(value, forHTTPHeaderField: key) + } + if request.value(forHTTPHeaderField: "Content-Type") == nil { + request.setValue(Self.contentTypes[name] ?? "application/octet-stream", + forHTTPHeaderField: "Content-Type") + } + let put = send(request, fromFile: dir.appendingPathComponent(name)) + guard (200..<300).contains(put) else { return .failed("PUT \(name) \(put)") } + } + + let (commitStatus, commitBody) = post(["action": "commit", "code": code], token: token) + if commitStatus == 401 || commitStatus == 403 { return .unauthorized } + guard commitStatus == 200 else { + return .failed("commit \(commitStatus) \(commitBody["missing"] ?? commitBody["error"] ?? "")") + } + return .done + } + + // MARK: - HTTP (blocking; only ever on `queue`) + + private func post(_ json: [String: Any], token: String) -> (Int, [String: Any]) { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("MenuBand", forHTTPHeaderField: "User-Agent") + request.httpBody = try? JSONSerialization.data(withJSONObject: json) + var status = 0 + var result: [String: Any] = [:] + let done = DispatchSemaphore(value: 0) + session.dataTask(with: request) { data, response, _ in + status = (response as? HTTPURLResponse)?.statusCode ?? 0 + if let data, let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + result = object + } + done.signal() + }.resume() + done.wait() + return (status, result) + } + + /// Streams the file from disk — stems never load into memory. + private func send(_ request: URLRequest, fromFile file: URL) -> Int { + var status = 0 + let done = DispatchSemaphore(value: 0) + session.uploadTask(with: request, fromFile: file) { _, response, _ in + status = (response as? HTTPURLResponse)?.statusCode ?? 0 + done.signal() + }.resume() + done.wait() + return status + } + + // MARK: - Helpers + + private func postStatus() { + DispatchQueue.main.async { NotificationCenter.default.post(name: Self.statusChanged, object: nil) } + } + + private static func pngData(_ image: NSImage) -> Data? { + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff) else { return nil } + return rep.representation(using: .png, properties: [:]) + } +} +#endif diff --git a/slab/menuband/Sources/MenuBand/MenuBandEngine.swift b/slab/menuband/Sources/MenuBand/MenuBandEngine.swift index 8a83803326..22bd3bdd6f 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandEngine.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandEngine.swift @@ -25,7 +25,7 @@ final class MenuBandEngine { private var useA = true // which bank currently sounds private(set) var running = false - private var bpm = 110.0 + private(set) var bpm = 110.0 private var stepBeats = 0.5 // arp/drum grid (0.5 = eighths) private var chord: [UInt8] = [60, 64, 67] private var arp: [Int] = [0, 1, 2, 1] // chord-tone indices, -1 = rest diff --git a/slab/menuband/Sources/MenuBand/ReadyChime.swift b/slab/menuband/Sources/MenuBand/ReadyChime.swift index 058e889aba..79835c03f8 100644 --- a/slab/menuband/Sources/MenuBand/ReadyChime.swift +++ b/slab/menuband/Sources/MenuBand/ReadyChime.swift @@ -21,15 +21,35 @@ final class ReadyChime { catch { NSLog("ReadyChime: engine start failed — \(error)") } } + private struct Bell { let freq: Double; let start: Double; let pan: Double } + /// Ring it: a staggered bell arpeggio spelling a bright D major add-shimmer, /// each note a decaying sine + a glassy 2nd partial, panned to fan out. func play() { + ring([ + Bell(freq: 587.33, start: 0.00, pan: -0.35), // D5 + Bell(freq: 739.99, start: 0.08, pan: -0.10), // F#5 + Bell(freq: 880.00, start: 0.16, pan: 0.15), // A5 + Bell(freq: 1174.66, start: 0.26, pan: 0.35), // D6 shimmer + ], seconds: 1.4) + } + + /// A take reached @handle: two high bells falling a fourth (D6 → A5), + /// quieter and shorter than the saved-on-Desktop arpeggio so the two + /// never read as the same event. + func playBackedUp() { + ring([ + Bell(freq: 1174.66, start: 0.00, pan: 0.25), // D6 + Bell(freq: 880.00, start: 0.12, pan: -0.25), // A5 + ], seconds: 1.0, gain: 0.2) + } + + private func ring(_ bells: [Bell], seconds dur: Double, gain: Double = 0.30) { ensureStarted() guard started, let fmt = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 2) else { return } - let dur = 1.4 let n = Int(sampleRate * dur) guard let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: AVAudioFrameCount(n)), let left = buf.floatChannelData?[0], @@ -37,13 +57,6 @@ final class ReadyChime { else { return } buf.frameLength = AVAudioFrameCount(n) - struct Bell { let freq: Double; let start: Double; let pan: Double } - let bells: [Bell] = [ - Bell(freq: 587.33, start: 0.00, pan: -0.35), // D5 - Bell(freq: 739.99, start: 0.08, pan: -0.10), // F#5 - Bell(freq: 880.00, start: 0.16, pan: 0.15), // A5 - Bell(freq: 1174.66, start: 0.26, pan: 0.35), // D6 shimmer - ] let twoPi = 2.0 * Double.pi for i in 0.. 0 { note += " \(pending) waiting to upload." } + self.cloudNote.stringValue = note + } + } + } #endif @objc private func viewCrashLogs(_ sender: Any?) { diff --git a/tests/ac-token.test.mjs b/tests/ac-token.test.mjs new file mode 100644 index 0000000000..ad9d2d138d --- /dev/null +++ b/tests/ac-token.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile, mkdir, utimes, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { freshSession } from "../shared/ac-token.mjs"; + +async function scratch(t, record) { + const dir = await mkdtemp(join(tmpdir(), "ac-token-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, ".ac-token"); + await writeFile(file, JSON.stringify(record)); + return file; +} + +// A fake Auth0 that rotates: each refresh token works once. +function rotatingAuth0(first) { + let live = first, calls = 0; + const fetch = async (_url, { body }) => { + calls += 1; + await new Promise((r) => setTimeout(r, 30)); + const { refresh_token } = JSON.parse(body); + if (refresh_token !== live) return { ok: false, status: 403, json: async () => ({}) }; + live = `r${calls}`; + return { ok: true, json: async () => ({ access_token: `a${calls}`, refresh_token: live, expires_in: 3600 }) }; + }; + return { fetch, calls: () => calls }; +} + +test("concurrent refreshes spend the refresh token once", async (t) => { + const file = await scratch(t, { access_token: "old", refresh_token: "r0", expires_at: Date.now() - 1, user: { handle: "jeffrey" } }); + const auth0 = rotatingAuth0("r0"); + const results = await Promise.all([1, 2, 3].map(() => freshSession({ file, fetch: auth0.fetch }))); + assert.equal(auth0.calls(), 1); + assert.deepEqual(results.map((r) => r.access_token), ["a1", "a1", "a1"]); + const saved = JSON.parse(await readFile(file, "utf8")); + assert.equal(saved.refresh_token, "r1"); + assert.equal(saved.user.handle, "jeffrey"); + await assert.rejects(stat(file + ".lock")); +}); + +test("a fresh token is left alone unless forced", async (t) => { + const file = await scratch(t, { access_token: "live", refresh_token: "r0", expires_at: Date.now() + 3600e3 }); + const auth0 = rotatingAuth0("r0"); + assert.equal((await freshSession({ file, fetch: auth0.fetch })).access_token, "live"); + assert.equal(auth0.calls(), 0); + assert.equal((await freshSession({ file, fetch: auth0.fetch, force: true })).access_token, "a1"); +}); + +test("a dead holder's lock is taken over", async (t) => { + const file = await scratch(t, { access_token: "old", refresh_token: "r0", expires_at: Date.now() - 1 }); + await mkdir(file + ".lock"); + const old = new Date(Date.now() - 60e3); + await utimes(file + ".lock", old, old); + const auth0 = rotatingAuth0("r0"); + assert.equal((await freshSession({ file, fetch: auth0.fetch })).access_token, "a1"); +}); diff --git a/tezos/ac-login.mjs b/tezos/ac-login.mjs index e0ff8578e4..0e4a51c640 100644 --- a/tezos/ac-login.mjs +++ b/tezos/ac-login.mjs @@ -321,6 +321,13 @@ async function startLocalCallbackServer(state, codeVerifier, codeChallenge, { fo }); } +// `ac-login refresh`: renew without a browser, under the same lock every +// other ~/.ac-token refresher on this Mac takes (shared/ac-token.mjs). +async function refresh({ force = false } = {}) { + const { freshSession } = await import('../shared/ac-token.mjs'); + return freshSession({ file: TOKEN_FILE, force }); +} + async function checkAuth() { try { const tokenData = await fs.readFile(TOKEN_FILE, 'utf8'); @@ -333,7 +340,7 @@ async function checkAuth() { console.log('╚══════════════════════════════════════════════════════════════╝\n'); if (expired) { - console.log('⚠️ Token expired - run `ac-login` to refresh\n'); + console.log('⚠️ Token expired - run `ac-login refresh` (or `ac-login`)\n'); } else { console.log('✅ Logged in\n'); } @@ -375,6 +382,14 @@ export async function getToken() { } if (command === 'status') { await checkAuth(); return; } + + if (command === 'refresh') { + try { + const next = await refresh({ force: process.argv.includes('--force') }); + console.log(`✅ Refreshed ${next.user?.handle ? '@' + next.user.handle : ''} until ${new Date(next.expires_at).toISOString()}`); + } catch (e) { console.error(e.message); process.exit(1); } + return; + } if (command === 'token') { try { @@ -396,6 +411,8 @@ Usage: ac-login Login (opens browser) ac-login fresh Login with forced account prompt ac-login status Check login status + ac-login refresh Renew the token from the refresh token (no browser; + skips if it has a minute left, --force to renew anyway) ac-login logout Clear local token + browser Auth0 session ac-login logout --local-only Clear local token only ac-login token Print access token (for scripts)