Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296// 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 <app>/Sources/<App>/`).//// 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": "<jwe>", "refresh_token": "...", "id_token": "...",// "expires_at": <ms-epoch>,// "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() }}