diff --git a/clip-wizard/Sources/ClipWizard/ACLogin.swift b/clip-wizard/Sources/ClipWizard/ACLogin.swift new file mode 100644 index 0000000000..7d22300b6c --- /dev/null +++ b/clip-wizard/Sources/ClipWizard/ACLogin.swift @@ -0,0 +1,406 @@ +// ACLogin.swift — native, in-app AC sign-in for the macOS app suite. +// +// SOURCE OF TRUTH: shared/swift/ACLogin.swift. Like ACSession.swift, the +// standalone SwiftPM apps (date-wizard, wave-wizard, clip-wizard, juke-wizard, +// slab menubar, menuband) each keep a COPY in their own Sources/. When you +// change this file, re-copy it into each consumer: +// cp shared/swift/ACLogin.swift /Sources// +// +// This is a native port of `tezos/ac-login.mjs` — the same OAuth 2.0 +// Authorization-Code + PKCE flow against Auth0 (`hi.aesthetic.computer`) with a +// localhost loopback callback on port 44233. It writes the SAME ~/.ac-token +// JSON shape that ACSession.swift reads, so a sign-in here is indistinguishable +// from one done by the `ac-login` CLI — the whole suite picks it up live via +// the shared file-watch. No Terminal, no Node, no CLI dependency. +// +// ACLogin.shared.signIn { result in +// switch result { +// case .success(let handle): … // "@handle" (or email/name fallback) +// case .failure(let error): … +// } +// } +// +// The callback fires on the main queue. ACSession's file-watch will ALSO fire +// independently the instant the token lands, so UIs that already observe the +// session refresh on their own — the completion handler is for surfacing +// progress/errors on the sign-in screen itself. +import Foundation +import AppKit +import CryptoKit + +final class ACLogin { + static let shared = ACLogin() + + // ── config (must match ac-login.mjs / Auth0 allowed callback) ──────── + private let authDomain = "hi.aesthetic.computer" + private let clientID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt" + private let callbackPort: UInt16 = 44233 + private var redirectURI: String { "http://localhost:\(callbackPort)/callback" } + + enum LoginError: LocalizedError { + case portInUse + case serverFailed(String) + case stateMismatch + case tokenExchange(String) + case cancelled + case timeout + + var errorDescription: String? { + switch self { + case .portInUse: return "Port \(44233) is in use — close any running ac-login and retry." + case .serverFailed(let m): return "Sign-in server failed: \(m)" + case .stateMismatch: return "Sign-in state mismatch — please retry." + case .tokenExchange(let m): return "Token exchange failed: \(m)" + case .cancelled: return "Sign-in cancelled." + case .timeout: return "Sign-in timed out." + } + } + } + + private var listener: SocketListener? + private var inFlight = false + + /// True while a sign-in is waiting on the browser/callback. + var isSigningIn: Bool { inFlight } + + // ── PKCE ───────────────────────────────────────────────────────────── + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + private func randomURLToken(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) + return base64URL(Data(bytes)) + } + + // ── public entry ─────────────────────────────────────────────────── + /// Start the browser sign-in flow. `forcePrompt` adds `prompt=login` to + /// force the Auth0 account chooser (the CLI's `ac-login fresh`). + func signIn(forcePrompt: Bool = false, + completion: @escaping (Result) -> Void) { + if inFlight { listener?.stop(); inFlight = false } + + let verifier = randomURLToken(byteCount: 32) + let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8)))) + let state = randomURLToken(byteCount: 16) + + let finish: (Result) -> Void = { [weak self] result in + guard let self else { return } + self.inFlight = false + self.listener?.stop() + self.listener = nil + DispatchQueue.main.async { completion(result) } + } + + let server: SocketListener + do { + server = try SocketListener(port: callbackPort) + } catch { + finish(.failure(LoginError.portInUse)) + return + } + listener = server + inFlight = true + + // 5-minute safety timeout, matching the CLI. + DispatchQueue.global().asyncAfter(deadline: .now() + 300) { [weak self] in + guard let self, self.inFlight else { return } + finish(.failure(LoginError.timeout)) + } + + server.onRequest = { [weak self] req, respond in + guard let self else { return } + guard req.path.contains("callback") else { + respond(404, "text/plain", "Not found"); return + } + if let err = req.query["error"] { + let desc = req.query["error_description"] ?? "" + respond(200, "text/html; charset=utf-8", Self.failureHTML(err, desc)) + finish(.failure(LoginError.tokenExchange(desc.isEmpty ? err : desc))) + return + } + guard let code = req.query["code"] else { + respond(400, "text/plain", "Missing authorization code"); return + } + guard req.query["state"] == state else { + respond(400, "text/plain", "State mismatch") + finish(.failure(LoginError.stateMismatch)) + return + } + // Exchange code → tokens, fetch userinfo + handle, write ~/.ac-token. + self.exchangeAndStore(code: code, verifier: verifier) { result in + switch result { + case .success(let display): + respond(200, "text/html; charset=utf-8", Self.successHTML(display)) + finish(.success(display)) + case .failure(let error): + respond(500, "text/plain", "Error: \(error.localizedDescription)") + finish(.failure(error)) + } + } + } + + do { + try server.start() + } catch { + finish(.failure(LoginError.serverFailed("\(error)"))) + return + } + + openBrowser(authURL(state: state, challenge: challenge, forcePrompt: forcePrompt)) + } + + /// Stop a pending sign-in (e.g. user navigated away). + func cancel() { + guard inFlight else { return } + inFlight = false + listener?.stop() + listener = nil + } + + // ── token exchange + persistence ───────────────────────────────────── + private func exchangeAndStore(code: String, verifier: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/oauth/token")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "grant_type": "authorization_code", + "client_id": clientID, + "code_verifier": verifier, + "code": code, + "redirect_uri": redirectURI, + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in + guard let self else { return } + if let err { completion(.failure(err)); return } + guard let data, + let http = resp as? HTTPURLResponse, http.statusCode == 200, + let tokens = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let access = tokens["access_token"] as? String else { + let txt = data.flatMap { String(data: $0, encoding: .utf8) } ?? "unknown" + completion(.failure(LoginError.tokenExchange(txt))) + return + } + self.fetchUserAndWrite(tokens: tokens, access: access, completion: completion) + }.resume() + } + + private func fetchUserAndWrite(tokens: [String: Any], access: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/userinfo")!) + req.setValue("Bearer \(access)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { [weak self] data, _, _ in + guard let self else { return } + let user = (data.flatMap { + (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] + }) ?? [:] + let sub = user["sub"] as? String + self.fetchHandle(sub: sub) { handle in + self.writeToken(tokens: tokens, user: user, handle: handle) + ACSession.shared.broadcastChanged() + let display = handle.map { "@\($0)" } + ?? (user["email"] as? String) + ?? (user["name"] as? String) + ?? "signed in" + completion(.success(display)) + } + }.resume() + } + + private func fetchHandle(sub: String?, completion: @escaping (String?) -> Void) { + guard let sub, let encoded = sub.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://aesthetic.computer/handle?for=\(encoded)") else { + completion(nil); return + } + URLSession.shared.dataTask(with: url) { data, _, _ in + let handle = data.flatMap { + (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["handle"] as? String + } + completion(handle) + }.resume() + } + + private func writeToken(tokens: [String: Any], user: [String: Any], handle: String?) { + let expiresIn = (tokens["expires_in"] as? Double) + ?? (tokens["expires_in"] as? Int).map(Double.init) ?? 0 + var payload: [String: Any] = [ + "access_token": tokens["access_token"] as? String ?? "", + "expires_at": Date().timeIntervalSince1970 * 1000 + expiresIn * 1000, + ] + if let r = tokens["refresh_token"] as? String { payload["refresh_token"] = r } + if let i = tokens["id_token"] as? String { payload["id_token"] = i } + var u: [String: Any] = [:] + if let v = user["email"] as? String { u["email"] = v } + if let v = user["name"] as? String { u["name"] = v } + if let v = user["sub"] as? String { u["sub"] = v } + if let v = user["picture"] as? String { u["picture"] = v } + if let handle { u["handle"] = handle } + payload["user"] = u + + guard let data = try? JSONSerialization.data( + withJSONObject: payload, options: [.prettyPrinted]) else { return } + // Atomic write so ACSession's file-watch sees a single clean change. + try? data.write(to: ACSession.tokenURL, options: [.atomic]) + } + + // ── URLs / browser ──────────────────────────────────────────────── + private func authURL(state: String, challenge: String, forcePrompt: Bool) -> URL { + var c = URLComponents(string: "https://\(authDomain)/authorize")! + var items = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "scope", value: "openid profile email offline_access"), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + if forcePrompt { items.append(URLQueryItem(name: "prompt", value: "login")) } + c.queryItems = items + return c.url! + } + + private func openBrowser(_ url: URL) { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + + // ── browser-facing HTML (mirrors ac-login.mjs styling) ─────────────── + private static func successHTML(_ display: String) -> String { + """ + + + Logged In · Aesthetic Computer

✅ Login Successful

+

Welcome, \(display)!

You may close this page.

+ + """ + } + private static func failureHTML(_ error: String, _ desc: String) -> String { + """ + + + Login Failed · Aesthetic Computer

❌ Authentication Failed

+

\(error)

\(desc)

You may close this page.

+ """ + } +} + +// ── minimal loopback HTTP server (BSD sockets) ─────────────────────────── +// A single-connection-at-a-time HTTP/1.0 responder. Enough to receive Auth0's +// redirect GET on 127.0.0.1 and reply with a close-this-page page. No external +// deps; avoids pulling in Network.framework just for one request. +private final class SocketListener { + struct Request { let path: String; let query: [String: String] } + + var onRequest: ((Request, _ respond: @escaping (Int, String, String) -> Void) -> Void)? + + private let port: UInt16 + private var fd: Int32 = -1 + private let queue = DispatchQueue(label: "ac.login.socket") + private var running = false + + init(port: UInt16) throws { + self.port = port + fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw ACLogin.LoginError.serverFailed("socket()") } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.portInUse + } + guard listen(fd, 4) == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.serverFailed("listen()") + } + } + + func start() throws { + running = true + queue.async { [weak self] in self?.acceptLoop() } + } + + func stop() { + running = false + if fd >= 0 { close(fd); fd = -1 } + } + + private func acceptLoop() { + while running { + let client = accept(fd, nil, nil) + if client < 0 { break } + handle(client) + // One callback redirect is all we need; keep looping in case the + // browser retries (favicon, double-fetch) until stop() closes fd. + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + var buf = [UInt8](repeating: 0, count: 8192) + let n = read(client, &buf, buf.count) + guard n > 0 else { return } + let raw = String(decoding: buf[0..= 2 else { return } + let target = String(parts[1]) + + let comps = URLComponents(string: "http://localhost\(target)") + let path = comps?.path ?? target + var query: [String: String] = [:] + for item in comps?.queryItems ?? [] { query[item.name] = item.value } + + let request = Request(path: path, query: query) + var responded = false + let respond: (Int, String, String) -> Void = { status, contentType, body in + guard !responded else { return } + responded = true + let bytes = Array(body.utf8) + let header = "HTTP/1.0 \(status) \(status == 200 ? "OK" : "Error")\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(bytes.count)\r\n" + + "Connection: close\r\n\r\n" + let out = Array(header.utf8) + bytes + _ = out.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + } + + if let onRequest { + onRequest(request, respond) + } else { + respond(404, "text/plain", "Not found") + } + // Give async respond() (token exchange) time to flush before close. + if !responded { + let deadline = Date().addingTimeInterval(310) + while !responded && Date() < deadline && running { + usleep(50_000) + } + } + } +} diff --git a/clip-wizard/Sources/ClipWizard/ACSession.swift b/clip-wizard/Sources/ClipWizard/ACSession.swift new file mode 100644 index 0000000000..26551e49cf --- /dev/null +++ b/clip-wizard/Sources/ClipWizard/ACSession.swift @@ -0,0 +1,206 @@ +// 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 + } + + /// 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/date-wizard/README.md b/date-wizard/README.md index 56a6168e36..3854b57f73 100644 --- a/date-wizard/README.md +++ b/date-wizard/README.md @@ -20,18 +20,23 @@ Requires macOS 12+ and a Swift 5.9 toolchain. ## Sign in (shared AC session) -DateWizard uses your shared AC session from `ac-login` (`~/.ac-token`) — the -same login `ac-os` uses. Run `ac-login` once to sign in. +DateWizard signs you in **natively, in-app** — no terminal, no CLI. It shares +the same `~/.ac-token` the rest of the AC suite uses, so one sign-in serves +`ac-os`, the other wizards, and the Electron tray. On first run (or after the token expires and a request comes back `401`), it shows a **sign-in screen**: -- **Sign in (run ac-login)** launches `ac-login` in Terminal so you can - authenticate without leaving the app. -- It then auto-polls `~/.ac-token` every ~2s and proceeds automatically once a - valid token appears. (Or click **I've signed in** to check immediately.) +- **Sign in** runs AC's OAuth2 + PKCE browser flow directly (`ACLogin.swift`): + it spins up a localhost loopback callback on port `44233`, opens your browser + to `hi.aesthetic.computer`, then on the redirect exchanges the code, fetches + your `@handle`, and writes `~/.ac-token` itself — the same token the + `ac-login` CLI would write. The app proceeds automatically the instant the + token lands (via the shared session file-watch). +- **Use ac-login CLI instead** is a fallback that launches `ac-login` in + Terminal — only needed if the native flow can't bind its loopback port. -The token file is JSON written by the AC stack: +The token file is JSON written either by the native flow or the AC stack: ``` ~/.ac-token # { "access_token": "", "refresh_token": "...", "expires_at": } @@ -39,8 +44,7 @@ The token file is JSON written by the AC stack: `access_token` is sent as `Authorization: Bearer …` on every `/api/cal` call. The token is treated as expired when the file is missing, unparseable, or -`expires_at` is in the past — at which point you're prompted to run `ac-login` -again. +`expires_at` is in the past — at which point you're prompted to sign in again. ## Using the week diff --git a/date-wizard/Sources/DateWizard/ACLogin.swift b/date-wizard/Sources/DateWizard/ACLogin.swift new file mode 100644 index 0000000000..7d22300b6c --- /dev/null +++ b/date-wizard/Sources/DateWizard/ACLogin.swift @@ -0,0 +1,406 @@ +// ACLogin.swift — native, in-app AC sign-in for the macOS app suite. +// +// SOURCE OF TRUTH: shared/swift/ACLogin.swift. Like ACSession.swift, the +// standalone SwiftPM apps (date-wizard, wave-wizard, clip-wizard, juke-wizard, +// slab menubar, menuband) each keep a COPY in their own Sources/. When you +// change this file, re-copy it into each consumer: +// cp shared/swift/ACLogin.swift /Sources// +// +// This is a native port of `tezos/ac-login.mjs` — the same OAuth 2.0 +// Authorization-Code + PKCE flow against Auth0 (`hi.aesthetic.computer`) with a +// localhost loopback callback on port 44233. It writes the SAME ~/.ac-token +// JSON shape that ACSession.swift reads, so a sign-in here is indistinguishable +// from one done by the `ac-login` CLI — the whole suite picks it up live via +// the shared file-watch. No Terminal, no Node, no CLI dependency. +// +// ACLogin.shared.signIn { result in +// switch result { +// case .success(let handle): … // "@handle" (or email/name fallback) +// case .failure(let error): … +// } +// } +// +// The callback fires on the main queue. ACSession's file-watch will ALSO fire +// independently the instant the token lands, so UIs that already observe the +// session refresh on their own — the completion handler is for surfacing +// progress/errors on the sign-in screen itself. +import Foundation +import AppKit +import CryptoKit + +final class ACLogin { + static let shared = ACLogin() + + // ── config (must match ac-login.mjs / Auth0 allowed callback) ──────── + private let authDomain = "hi.aesthetic.computer" + private let clientID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt" + private let callbackPort: UInt16 = 44233 + private var redirectURI: String { "http://localhost:\(callbackPort)/callback" } + + enum LoginError: LocalizedError { + case portInUse + case serverFailed(String) + case stateMismatch + case tokenExchange(String) + case cancelled + case timeout + + var errorDescription: String? { + switch self { + case .portInUse: return "Port \(44233) is in use — close any running ac-login and retry." + case .serverFailed(let m): return "Sign-in server failed: \(m)" + case .stateMismatch: return "Sign-in state mismatch — please retry." + case .tokenExchange(let m): return "Token exchange failed: \(m)" + case .cancelled: return "Sign-in cancelled." + case .timeout: return "Sign-in timed out." + } + } + } + + private var listener: SocketListener? + private var inFlight = false + + /// True while a sign-in is waiting on the browser/callback. + var isSigningIn: Bool { inFlight } + + // ── PKCE ───────────────────────────────────────────────────────────── + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + private func randomURLToken(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) + return base64URL(Data(bytes)) + } + + // ── public entry ─────────────────────────────────────────────────── + /// Start the browser sign-in flow. `forcePrompt` adds `prompt=login` to + /// force the Auth0 account chooser (the CLI's `ac-login fresh`). + func signIn(forcePrompt: Bool = false, + completion: @escaping (Result) -> Void) { + if inFlight { listener?.stop(); inFlight = false } + + let verifier = randomURLToken(byteCount: 32) + let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8)))) + let state = randomURLToken(byteCount: 16) + + let finish: (Result) -> Void = { [weak self] result in + guard let self else { return } + self.inFlight = false + self.listener?.stop() + self.listener = nil + DispatchQueue.main.async { completion(result) } + } + + let server: SocketListener + do { + server = try SocketListener(port: callbackPort) + } catch { + finish(.failure(LoginError.portInUse)) + return + } + listener = server + inFlight = true + + // 5-minute safety timeout, matching the CLI. + DispatchQueue.global().asyncAfter(deadline: .now() + 300) { [weak self] in + guard let self, self.inFlight else { return } + finish(.failure(LoginError.timeout)) + } + + server.onRequest = { [weak self] req, respond in + guard let self else { return } + guard req.path.contains("callback") else { + respond(404, "text/plain", "Not found"); return + } + if let err = req.query["error"] { + let desc = req.query["error_description"] ?? "" + respond(200, "text/html; charset=utf-8", Self.failureHTML(err, desc)) + finish(.failure(LoginError.tokenExchange(desc.isEmpty ? err : desc))) + return + } + guard let code = req.query["code"] else { + respond(400, "text/plain", "Missing authorization code"); return + } + guard req.query["state"] == state else { + respond(400, "text/plain", "State mismatch") + finish(.failure(LoginError.stateMismatch)) + return + } + // Exchange code → tokens, fetch userinfo + handle, write ~/.ac-token. + self.exchangeAndStore(code: code, verifier: verifier) { result in + switch result { + case .success(let display): + respond(200, "text/html; charset=utf-8", Self.successHTML(display)) + finish(.success(display)) + case .failure(let error): + respond(500, "text/plain", "Error: \(error.localizedDescription)") + finish(.failure(error)) + } + } + } + + do { + try server.start() + } catch { + finish(.failure(LoginError.serverFailed("\(error)"))) + return + } + + openBrowser(authURL(state: state, challenge: challenge, forcePrompt: forcePrompt)) + } + + /// Stop a pending sign-in (e.g. user navigated away). + func cancel() { + guard inFlight else { return } + inFlight = false + listener?.stop() + listener = nil + } + + // ── token exchange + persistence ───────────────────────────────────── + private func exchangeAndStore(code: String, verifier: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/oauth/token")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "grant_type": "authorization_code", + "client_id": clientID, + "code_verifier": verifier, + "code": code, + "redirect_uri": redirectURI, + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in + guard let self else { return } + if let err { completion(.failure(err)); return } + guard let data, + let http = resp as? HTTPURLResponse, http.statusCode == 200, + let tokens = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let access = tokens["access_token"] as? String else { + let txt = data.flatMap { String(data: $0, encoding: .utf8) } ?? "unknown" + completion(.failure(LoginError.tokenExchange(txt))) + return + } + self.fetchUserAndWrite(tokens: tokens, access: access, completion: completion) + }.resume() + } + + private func fetchUserAndWrite(tokens: [String: Any], access: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/userinfo")!) + req.setValue("Bearer \(access)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { [weak self] data, _, _ in + guard let self else { return } + let user = (data.flatMap { + (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] + }) ?? [:] + let sub = user["sub"] as? String + self.fetchHandle(sub: sub) { handle in + self.writeToken(tokens: tokens, user: user, handle: handle) + ACSession.shared.broadcastChanged() + let display = handle.map { "@\($0)" } + ?? (user["email"] as? String) + ?? (user["name"] as? String) + ?? "signed in" + completion(.success(display)) + } + }.resume() + } + + private func fetchHandle(sub: String?, completion: @escaping (String?) -> Void) { + guard let sub, let encoded = sub.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://aesthetic.computer/handle?for=\(encoded)") else { + completion(nil); return + } + URLSession.shared.dataTask(with: url) { data, _, _ in + let handle = data.flatMap { + (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["handle"] as? String + } + completion(handle) + }.resume() + } + + private func writeToken(tokens: [String: Any], user: [String: Any], handle: String?) { + let expiresIn = (tokens["expires_in"] as? Double) + ?? (tokens["expires_in"] as? Int).map(Double.init) ?? 0 + var payload: [String: Any] = [ + "access_token": tokens["access_token"] as? String ?? "", + "expires_at": Date().timeIntervalSince1970 * 1000 + expiresIn * 1000, + ] + if let r = tokens["refresh_token"] as? String { payload["refresh_token"] = r } + if let i = tokens["id_token"] as? String { payload["id_token"] = i } + var u: [String: Any] = [:] + if let v = user["email"] as? String { u["email"] = v } + if let v = user["name"] as? String { u["name"] = v } + if let v = user["sub"] as? String { u["sub"] = v } + if let v = user["picture"] as? String { u["picture"] = v } + if let handle { u["handle"] = handle } + payload["user"] = u + + guard let data = try? JSONSerialization.data( + withJSONObject: payload, options: [.prettyPrinted]) else { return } + // Atomic write so ACSession's file-watch sees a single clean change. + try? data.write(to: ACSession.tokenURL, options: [.atomic]) + } + + // ── URLs / browser ──────────────────────────────────────────────── + private func authURL(state: String, challenge: String, forcePrompt: Bool) -> URL { + var c = URLComponents(string: "https://\(authDomain)/authorize")! + var items = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "scope", value: "openid profile email offline_access"), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + if forcePrompt { items.append(URLQueryItem(name: "prompt", value: "login")) } + c.queryItems = items + return c.url! + } + + private func openBrowser(_ url: URL) { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + + // ── browser-facing HTML (mirrors ac-login.mjs styling) ─────────────── + private static func successHTML(_ display: String) -> String { + """ + + + Logged In · Aesthetic Computer

✅ Login Successful

+

Welcome, \(display)!

You may close this page.

+ + """ + } + private static func failureHTML(_ error: String, _ desc: String) -> String { + """ + + + Login Failed · Aesthetic Computer

❌ Authentication Failed

+

\(error)

\(desc)

You may close this page.

+ """ + } +} + +// ── minimal loopback HTTP server (BSD sockets) ─────────────────────────── +// A single-connection-at-a-time HTTP/1.0 responder. Enough to receive Auth0's +// redirect GET on 127.0.0.1 and reply with a close-this-page page. No external +// deps; avoids pulling in Network.framework just for one request. +private final class SocketListener { + struct Request { let path: String; let query: [String: String] } + + var onRequest: ((Request, _ respond: @escaping (Int, String, String) -> Void) -> Void)? + + private let port: UInt16 + private var fd: Int32 = -1 + private let queue = DispatchQueue(label: "ac.login.socket") + private var running = false + + init(port: UInt16) throws { + self.port = port + fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw ACLogin.LoginError.serverFailed("socket()") } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.portInUse + } + guard listen(fd, 4) == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.serverFailed("listen()") + } + } + + func start() throws { + running = true + queue.async { [weak self] in self?.acceptLoop() } + } + + func stop() { + running = false + if fd >= 0 { close(fd); fd = -1 } + } + + private func acceptLoop() { + while running { + let client = accept(fd, nil, nil) + if client < 0 { break } + handle(client) + // One callback redirect is all we need; keep looping in case the + // browser retries (favicon, double-fetch) until stop() closes fd. + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + var buf = [UInt8](repeating: 0, count: 8192) + let n = read(client, &buf, buf.count) + guard n > 0 else { return } + let raw = String(decoding: buf[0..= 2 else { return } + let target = String(parts[1]) + + let comps = URLComponents(string: "http://localhost\(target)") + let path = comps?.path ?? target + var query: [String: String] = [:] + for item in comps?.queryItems ?? [] { query[item.name] = item.value } + + let request = Request(path: path, query: query) + var responded = false + let respond: (Int, String, String) -> Void = { status, contentType, body in + guard !responded else { return } + responded = true + let bytes = Array(body.utf8) + let header = "HTTP/1.0 \(status) \(status == 200 ? "OK" : "Error")\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(bytes.count)\r\n" + + "Connection: close\r\n\r\n" + let out = Array(header.utf8) + bytes + _ = out.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + } + + if let onRequest { + onRequest(request, respond) + } else { + respond(404, "text/plain", "Not found") + } + // Give async respond() (token exchange) time to flush before close. + if !responded { + let deadline = Date().addingTimeInterval(310) + while !responded && Date() < deadline && running { + usleep(50_000) + } + } + } +} diff --git a/date-wizard/Sources/DateWizard/Auth.swift b/date-wizard/Sources/DateWizard/Auth.swift index 7e0448b9c0..66eda6a54b 100644 --- a/date-wizard/Sources/DateWizard/Auth.swift +++ b/date-wizard/Sources/DateWizard/Auth.swift @@ -27,6 +27,18 @@ final class Auth { } func stopWatching(_ id: UUID) { session.stopWatching(id) } - /// Launch `ac-login` in Terminal to (re)write ~/.ac-token. + /// Native in-app sign-in — runs AC's OAuth2 + PKCE browser flow and writes + /// ~/.ac-token directly, no CLI/Terminal. `completion` gets "@handle" (or an + /// email/name fallback) on success. ACSession's file-watch ALSO fires the + /// instant the token lands, so observers refresh on their own. + func signIn(forcePrompt: Bool = false, + completion: @escaping (Result) -> Void) { + ACLogin.shared.signIn(forcePrompt: forcePrompt, completion: completion) + } + + var isSigningIn: Bool { ACLogin.shared.isSigningIn } + + /// Fallback: launch the `ac-login` CLI in Terminal (used only if the native + /// flow can't bind its loopback port). func runAcLogin() { session.runAcLogin() } } diff --git a/date-wizard/Sources/DateWizard/WizardController.swift b/date-wizard/Sources/DateWizard/WizardController.swift index 840b02948a..0a75ef3d5a 100644 --- a/date-wizard/Sources/DateWizard/WizardController.swift +++ b/date-wizard/Sources/DateWizard/WizardController.swift @@ -631,7 +631,7 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele title.autoresizingMask = [.width, .minYMargin] overlay.addSubview(title) - let instr = NSTextField(labelWithString: "Run ac-login in your terminal") + let instr = NSTextField(labelWithString: "Sign in with your browser — no terminal needed") instr.alignment = .center instr.font = .systemFont(ofSize: 13) instr.textColor = .secondaryLabelColor @@ -639,8 +639,8 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele instr.autoresizingMask = [.width, .minYMargin] overlay.addSubview(instr) - let signInBtn = NSButton(title: "Sign in (run ac-login)", - target: self, action: #selector(runAcLoginAction)) + let signInBtn = NSButton(title: "Sign in", + target: self, action: #selector(signInAction)) signInBtn.bezelStyle = .rounded signInBtn.keyEquivalent = "\r" signInBtn.frame = NSRect(x: cv.bounds.width / 2 - 110, y: cv.bounds.height - 240, @@ -648,15 +648,18 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele signInBtn.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin] overlay.addSubview(signInBtn) - let signedInBtn = NSButton(title: "I've signed in", - target: self, action: #selector(checkSignedInAction)) - signedInBtn.bezelStyle = .rounded - signedInBtn.frame = NSRect(x: cv.bounds.width / 2 - 70, y: cv.bounds.height - 278, - width: 140, height: 28) - signedInBtn.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin] - overlay.addSubview(signedInBtn) - - let status = NSTextField(labelWithString: "Waiting for ~/.ac-token…") + let cliBtn = NSButton(title: "Use ac-login CLI instead", + target: self, action: #selector(runAcLoginAction)) + cliBtn.bezelStyle = .inline + cliBtn.isBordered = false + cliBtn.contentTintColor = .tertiaryLabelColor + cliBtn.font = .systemFont(ofSize: 11) + cliBtn.frame = NSRect(x: cv.bounds.width / 2 - 110, y: cv.bounds.height - 274, + width: 220, height: 22) + cliBtn.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin] + overlay.addSubview(cliBtn) + + let status = NSTextField(labelWithString: "") status.alignment = .center status.font = .systemFont(ofSize: 12) status.textColor = .tertiaryLabelColor @@ -677,19 +680,26 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele authStatusLabel = nil } + @objc private func signInAction() { + authStatusLabel?.stringValue = "Opening browser… finish signing in there." + auth.signIn { [weak self] result in + guard let self else { return } + switch result { + case .success: + // The session file-watch (start()) will proceed automatically; + // call directly too so we don't wait on the debounce. + self.handleSessionChange() + case .failure(let error): + self.authStatusLabel?.stringValue = error.localizedDescription + } + } + } + @objc private func runAcLoginAction() { auth.runAcLogin() authStatusLabel?.stringValue = "Finish signing in your terminal — waiting for ~/.ac-token…" } - @objc private func checkSignedInAction() { - if auth.currentToken() != nil { - handleSessionChange() - } else { - authStatusLabel?.stringValue = "No token yet — run ac-login in your terminal first." - } - } - // ── parsing helpers ────────────────────────────────────────────── private static let isoFractional: ISO8601DateFormatter = { let f = ISO8601DateFormatter() diff --git a/juke-wizard/Sources/JukeWizard/ACLogin.swift b/juke-wizard/Sources/JukeWizard/ACLogin.swift new file mode 100644 index 0000000000..7d22300b6c --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/ACLogin.swift @@ -0,0 +1,406 @@ +// ACLogin.swift — native, in-app AC sign-in for the macOS app suite. +// +// SOURCE OF TRUTH: shared/swift/ACLogin.swift. Like ACSession.swift, the +// standalone SwiftPM apps (date-wizard, wave-wizard, clip-wizard, juke-wizard, +// slab menubar, menuband) each keep a COPY in their own Sources/. When you +// change this file, re-copy it into each consumer: +// cp shared/swift/ACLogin.swift /Sources// +// +// This is a native port of `tezos/ac-login.mjs` — the same OAuth 2.0 +// Authorization-Code + PKCE flow against Auth0 (`hi.aesthetic.computer`) with a +// localhost loopback callback on port 44233. It writes the SAME ~/.ac-token +// JSON shape that ACSession.swift reads, so a sign-in here is indistinguishable +// from one done by the `ac-login` CLI — the whole suite picks it up live via +// the shared file-watch. No Terminal, no Node, no CLI dependency. +// +// ACLogin.shared.signIn { result in +// switch result { +// case .success(let handle): … // "@handle" (or email/name fallback) +// case .failure(let error): … +// } +// } +// +// The callback fires on the main queue. ACSession's file-watch will ALSO fire +// independently the instant the token lands, so UIs that already observe the +// session refresh on their own — the completion handler is for surfacing +// progress/errors on the sign-in screen itself. +import Foundation +import AppKit +import CryptoKit + +final class ACLogin { + static let shared = ACLogin() + + // ── config (must match ac-login.mjs / Auth0 allowed callback) ──────── + private let authDomain = "hi.aesthetic.computer" + private let clientID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt" + private let callbackPort: UInt16 = 44233 + private var redirectURI: String { "http://localhost:\(callbackPort)/callback" } + + enum LoginError: LocalizedError { + case portInUse + case serverFailed(String) + case stateMismatch + case tokenExchange(String) + case cancelled + case timeout + + var errorDescription: String? { + switch self { + case .portInUse: return "Port \(44233) is in use — close any running ac-login and retry." + case .serverFailed(let m): return "Sign-in server failed: \(m)" + case .stateMismatch: return "Sign-in state mismatch — please retry." + case .tokenExchange(let m): return "Token exchange failed: \(m)" + case .cancelled: return "Sign-in cancelled." + case .timeout: return "Sign-in timed out." + } + } + } + + private var listener: SocketListener? + private var inFlight = false + + /// True while a sign-in is waiting on the browser/callback. + var isSigningIn: Bool { inFlight } + + // ── PKCE ───────────────────────────────────────────────────────────── + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + private func randomURLToken(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) + return base64URL(Data(bytes)) + } + + // ── public entry ─────────────────────────────────────────────────── + /// Start the browser sign-in flow. `forcePrompt` adds `prompt=login` to + /// force the Auth0 account chooser (the CLI's `ac-login fresh`). + func signIn(forcePrompt: Bool = false, + completion: @escaping (Result) -> Void) { + if inFlight { listener?.stop(); inFlight = false } + + let verifier = randomURLToken(byteCount: 32) + let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8)))) + let state = randomURLToken(byteCount: 16) + + let finish: (Result) -> Void = { [weak self] result in + guard let self else { return } + self.inFlight = false + self.listener?.stop() + self.listener = nil + DispatchQueue.main.async { completion(result) } + } + + let server: SocketListener + do { + server = try SocketListener(port: callbackPort) + } catch { + finish(.failure(LoginError.portInUse)) + return + } + listener = server + inFlight = true + + // 5-minute safety timeout, matching the CLI. + DispatchQueue.global().asyncAfter(deadline: .now() + 300) { [weak self] in + guard let self, self.inFlight else { return } + finish(.failure(LoginError.timeout)) + } + + server.onRequest = { [weak self] req, respond in + guard let self else { return } + guard req.path.contains("callback") else { + respond(404, "text/plain", "Not found"); return + } + if let err = req.query["error"] { + let desc = req.query["error_description"] ?? "" + respond(200, "text/html; charset=utf-8", Self.failureHTML(err, desc)) + finish(.failure(LoginError.tokenExchange(desc.isEmpty ? err : desc))) + return + } + guard let code = req.query["code"] else { + respond(400, "text/plain", "Missing authorization code"); return + } + guard req.query["state"] == state else { + respond(400, "text/plain", "State mismatch") + finish(.failure(LoginError.stateMismatch)) + return + } + // Exchange code → tokens, fetch userinfo + handle, write ~/.ac-token. + self.exchangeAndStore(code: code, verifier: verifier) { result in + switch result { + case .success(let display): + respond(200, "text/html; charset=utf-8", Self.successHTML(display)) + finish(.success(display)) + case .failure(let error): + respond(500, "text/plain", "Error: \(error.localizedDescription)") + finish(.failure(error)) + } + } + } + + do { + try server.start() + } catch { + finish(.failure(LoginError.serverFailed("\(error)"))) + return + } + + openBrowser(authURL(state: state, challenge: challenge, forcePrompt: forcePrompt)) + } + + /// Stop a pending sign-in (e.g. user navigated away). + func cancel() { + guard inFlight else { return } + inFlight = false + listener?.stop() + listener = nil + } + + // ── token exchange + persistence ───────────────────────────────────── + private func exchangeAndStore(code: String, verifier: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/oauth/token")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "grant_type": "authorization_code", + "client_id": clientID, + "code_verifier": verifier, + "code": code, + "redirect_uri": redirectURI, + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in + guard let self else { return } + if let err { completion(.failure(err)); return } + guard let data, + let http = resp as? HTTPURLResponse, http.statusCode == 200, + let tokens = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let access = tokens["access_token"] as? String else { + let txt = data.flatMap { String(data: $0, encoding: .utf8) } ?? "unknown" + completion(.failure(LoginError.tokenExchange(txt))) + return + } + self.fetchUserAndWrite(tokens: tokens, access: access, completion: completion) + }.resume() + } + + private func fetchUserAndWrite(tokens: [String: Any], access: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/userinfo")!) + req.setValue("Bearer \(access)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { [weak self] data, _, _ in + guard let self else { return } + let user = (data.flatMap { + (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] + }) ?? [:] + let sub = user["sub"] as? String + self.fetchHandle(sub: sub) { handle in + self.writeToken(tokens: tokens, user: user, handle: handle) + ACSession.shared.broadcastChanged() + let display = handle.map { "@\($0)" } + ?? (user["email"] as? String) + ?? (user["name"] as? String) + ?? "signed in" + completion(.success(display)) + } + }.resume() + } + + private func fetchHandle(sub: String?, completion: @escaping (String?) -> Void) { + guard let sub, let encoded = sub.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://aesthetic.computer/handle?for=\(encoded)") else { + completion(nil); return + } + URLSession.shared.dataTask(with: url) { data, _, _ in + let handle = data.flatMap { + (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["handle"] as? String + } + completion(handle) + }.resume() + } + + private func writeToken(tokens: [String: Any], user: [String: Any], handle: String?) { + let expiresIn = (tokens["expires_in"] as? Double) + ?? (tokens["expires_in"] as? Int).map(Double.init) ?? 0 + var payload: [String: Any] = [ + "access_token": tokens["access_token"] as? String ?? "", + "expires_at": Date().timeIntervalSince1970 * 1000 + expiresIn * 1000, + ] + if let r = tokens["refresh_token"] as? String { payload["refresh_token"] = r } + if let i = tokens["id_token"] as? String { payload["id_token"] = i } + var u: [String: Any] = [:] + if let v = user["email"] as? String { u["email"] = v } + if let v = user["name"] as? String { u["name"] = v } + if let v = user["sub"] as? String { u["sub"] = v } + if let v = user["picture"] as? String { u["picture"] = v } + if let handle { u["handle"] = handle } + payload["user"] = u + + guard let data = try? JSONSerialization.data( + withJSONObject: payload, options: [.prettyPrinted]) else { return } + // Atomic write so ACSession's file-watch sees a single clean change. + try? data.write(to: ACSession.tokenURL, options: [.atomic]) + } + + // ── URLs / browser ──────────────────────────────────────────────── + private func authURL(state: String, challenge: String, forcePrompt: Bool) -> URL { + var c = URLComponents(string: "https://\(authDomain)/authorize")! + var items = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "scope", value: "openid profile email offline_access"), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + if forcePrompt { items.append(URLQueryItem(name: "prompt", value: "login")) } + c.queryItems = items + return c.url! + } + + private func openBrowser(_ url: URL) { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + + // ── browser-facing HTML (mirrors ac-login.mjs styling) ─────────────── + private static func successHTML(_ display: String) -> String { + """ + + + Logged In · Aesthetic Computer

✅ Login Successful

+

Welcome, \(display)!

You may close this page.

+ + """ + } + private static func failureHTML(_ error: String, _ desc: String) -> String { + """ + + + Login Failed · Aesthetic Computer

❌ Authentication Failed

+

\(error)

\(desc)

You may close this page.

+ """ + } +} + +// ── minimal loopback HTTP server (BSD sockets) ─────────────────────────── +// A single-connection-at-a-time HTTP/1.0 responder. Enough to receive Auth0's +// redirect GET on 127.0.0.1 and reply with a close-this-page page. No external +// deps; avoids pulling in Network.framework just for one request. +private final class SocketListener { + struct Request { let path: String; let query: [String: String] } + + var onRequest: ((Request, _ respond: @escaping (Int, String, String) -> Void) -> Void)? + + private let port: UInt16 + private var fd: Int32 = -1 + private let queue = DispatchQueue(label: "ac.login.socket") + private var running = false + + init(port: UInt16) throws { + self.port = port + fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw ACLogin.LoginError.serverFailed("socket()") } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.portInUse + } + guard listen(fd, 4) == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.serverFailed("listen()") + } + } + + func start() throws { + running = true + queue.async { [weak self] in self?.acceptLoop() } + } + + func stop() { + running = false + if fd >= 0 { close(fd); fd = -1 } + } + + private func acceptLoop() { + while running { + let client = accept(fd, nil, nil) + if client < 0 { break } + handle(client) + // One callback redirect is all we need; keep looping in case the + // browser retries (favicon, double-fetch) until stop() closes fd. + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + var buf = [UInt8](repeating: 0, count: 8192) + let n = read(client, &buf, buf.count) + guard n > 0 else { return } + let raw = String(decoding: buf[0..= 2 else { return } + let target = String(parts[1]) + + let comps = URLComponents(string: "http://localhost\(target)") + let path = comps?.path ?? target + var query: [String: String] = [:] + for item in comps?.queryItems ?? [] { query[item.name] = item.value } + + let request = Request(path: path, query: query) + var responded = false + let respond: (Int, String, String) -> Void = { status, contentType, body in + guard !responded else { return } + responded = true + let bytes = Array(body.utf8) + let header = "HTTP/1.0 \(status) \(status == 200 ? "OK" : "Error")\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(bytes.count)\r\n" + + "Connection: close\r\n\r\n" + let out = Array(header.utf8) + bytes + _ = out.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + } + + if let onRequest { + onRequest(request, respond) + } else { + respond(404, "text/plain", "Not found") + } + // Give async respond() (token exchange) time to flush before close. + if !responded { + let deadline = Date().addingTimeInterval(310) + while !responded && Date() < deadline && running { + usleep(50_000) + } + } + } +} diff --git a/juke-wizard/Sources/JukeWizard/ACSession.swift b/juke-wizard/Sources/JukeWizard/ACSession.swift new file mode 100644 index 0000000000..26551e49cf --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/ACSession.swift @@ -0,0 +1,206 @@ +// 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 + } + + /// 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/shared/swift/ACLogin.swift b/shared/swift/ACLogin.swift new file mode 100644 index 0000000000..7d22300b6c --- /dev/null +++ b/shared/swift/ACLogin.swift @@ -0,0 +1,406 @@ +// ACLogin.swift — native, in-app AC sign-in for the macOS app suite. +// +// SOURCE OF TRUTH: shared/swift/ACLogin.swift. Like ACSession.swift, the +// standalone SwiftPM apps (date-wizard, wave-wizard, clip-wizard, juke-wizard, +// slab menubar, menuband) each keep a COPY in their own Sources/. When you +// change this file, re-copy it into each consumer: +// cp shared/swift/ACLogin.swift /Sources// +// +// This is a native port of `tezos/ac-login.mjs` — the same OAuth 2.0 +// Authorization-Code + PKCE flow against Auth0 (`hi.aesthetic.computer`) with a +// localhost loopback callback on port 44233. It writes the SAME ~/.ac-token +// JSON shape that ACSession.swift reads, so a sign-in here is indistinguishable +// from one done by the `ac-login` CLI — the whole suite picks it up live via +// the shared file-watch. No Terminal, no Node, no CLI dependency. +// +// ACLogin.shared.signIn { result in +// switch result { +// case .success(let handle): … // "@handle" (or email/name fallback) +// case .failure(let error): … +// } +// } +// +// The callback fires on the main queue. ACSession's file-watch will ALSO fire +// independently the instant the token lands, so UIs that already observe the +// session refresh on their own — the completion handler is for surfacing +// progress/errors on the sign-in screen itself. +import Foundation +import AppKit +import CryptoKit + +final class ACLogin { + static let shared = ACLogin() + + // ── config (must match ac-login.mjs / Auth0 allowed callback) ──────── + private let authDomain = "hi.aesthetic.computer" + private let clientID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt" + private let callbackPort: UInt16 = 44233 + private var redirectURI: String { "http://localhost:\(callbackPort)/callback" } + + enum LoginError: LocalizedError { + case portInUse + case serverFailed(String) + case stateMismatch + case tokenExchange(String) + case cancelled + case timeout + + var errorDescription: String? { + switch self { + case .portInUse: return "Port \(44233) is in use — close any running ac-login and retry." + case .serverFailed(let m): return "Sign-in server failed: \(m)" + case .stateMismatch: return "Sign-in state mismatch — please retry." + case .tokenExchange(let m): return "Token exchange failed: \(m)" + case .cancelled: return "Sign-in cancelled." + case .timeout: return "Sign-in timed out." + } + } + } + + private var listener: SocketListener? + private var inFlight = false + + /// True while a sign-in is waiting on the browser/callback. + var isSigningIn: Bool { inFlight } + + // ── PKCE ───────────────────────────────────────────────────────────── + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + private func randomURLToken(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) + return base64URL(Data(bytes)) + } + + // ── public entry ─────────────────────────────────────────────────── + /// Start the browser sign-in flow. `forcePrompt` adds `prompt=login` to + /// force the Auth0 account chooser (the CLI's `ac-login fresh`). + func signIn(forcePrompt: Bool = false, + completion: @escaping (Result) -> Void) { + if inFlight { listener?.stop(); inFlight = false } + + let verifier = randomURLToken(byteCount: 32) + let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8)))) + let state = randomURLToken(byteCount: 16) + + let finish: (Result) -> Void = { [weak self] result in + guard let self else { return } + self.inFlight = false + self.listener?.stop() + self.listener = nil + DispatchQueue.main.async { completion(result) } + } + + let server: SocketListener + do { + server = try SocketListener(port: callbackPort) + } catch { + finish(.failure(LoginError.portInUse)) + return + } + listener = server + inFlight = true + + // 5-minute safety timeout, matching the CLI. + DispatchQueue.global().asyncAfter(deadline: .now() + 300) { [weak self] in + guard let self, self.inFlight else { return } + finish(.failure(LoginError.timeout)) + } + + server.onRequest = { [weak self] req, respond in + guard let self else { return } + guard req.path.contains("callback") else { + respond(404, "text/plain", "Not found"); return + } + if let err = req.query["error"] { + let desc = req.query["error_description"] ?? "" + respond(200, "text/html; charset=utf-8", Self.failureHTML(err, desc)) + finish(.failure(LoginError.tokenExchange(desc.isEmpty ? err : desc))) + return + } + guard let code = req.query["code"] else { + respond(400, "text/plain", "Missing authorization code"); return + } + guard req.query["state"] == state else { + respond(400, "text/plain", "State mismatch") + finish(.failure(LoginError.stateMismatch)) + return + } + // Exchange code → tokens, fetch userinfo + handle, write ~/.ac-token. + self.exchangeAndStore(code: code, verifier: verifier) { result in + switch result { + case .success(let display): + respond(200, "text/html; charset=utf-8", Self.successHTML(display)) + finish(.success(display)) + case .failure(let error): + respond(500, "text/plain", "Error: \(error.localizedDescription)") + finish(.failure(error)) + } + } + } + + do { + try server.start() + } catch { + finish(.failure(LoginError.serverFailed("\(error)"))) + return + } + + openBrowser(authURL(state: state, challenge: challenge, forcePrompt: forcePrompt)) + } + + /// Stop a pending sign-in (e.g. user navigated away). + func cancel() { + guard inFlight else { return } + inFlight = false + listener?.stop() + listener = nil + } + + // ── token exchange + persistence ───────────────────────────────────── + private func exchangeAndStore(code: String, verifier: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/oauth/token")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "grant_type": "authorization_code", + "client_id": clientID, + "code_verifier": verifier, + "code": code, + "redirect_uri": redirectURI, + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in + guard let self else { return } + if let err { completion(.failure(err)); return } + guard let data, + let http = resp as? HTTPURLResponse, http.statusCode == 200, + let tokens = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let access = tokens["access_token"] as? String else { + let txt = data.flatMap { String(data: $0, encoding: .utf8) } ?? "unknown" + completion(.failure(LoginError.tokenExchange(txt))) + return + } + self.fetchUserAndWrite(tokens: tokens, access: access, completion: completion) + }.resume() + } + + private func fetchUserAndWrite(tokens: [String: Any], access: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/userinfo")!) + req.setValue("Bearer \(access)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { [weak self] data, _, _ in + guard let self else { return } + let user = (data.flatMap { + (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] + }) ?? [:] + let sub = user["sub"] as? String + self.fetchHandle(sub: sub) { handle in + self.writeToken(tokens: tokens, user: user, handle: handle) + ACSession.shared.broadcastChanged() + let display = handle.map { "@\($0)" } + ?? (user["email"] as? String) + ?? (user["name"] as? String) + ?? "signed in" + completion(.success(display)) + } + }.resume() + } + + private func fetchHandle(sub: String?, completion: @escaping (String?) -> Void) { + guard let sub, let encoded = sub.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://aesthetic.computer/handle?for=\(encoded)") else { + completion(nil); return + } + URLSession.shared.dataTask(with: url) { data, _, _ in + let handle = data.flatMap { + (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["handle"] as? String + } + completion(handle) + }.resume() + } + + private func writeToken(tokens: [String: Any], user: [String: Any], handle: String?) { + let expiresIn = (tokens["expires_in"] as? Double) + ?? (tokens["expires_in"] as? Int).map(Double.init) ?? 0 + var payload: [String: Any] = [ + "access_token": tokens["access_token"] as? String ?? "", + "expires_at": Date().timeIntervalSince1970 * 1000 + expiresIn * 1000, + ] + if let r = tokens["refresh_token"] as? String { payload["refresh_token"] = r } + if let i = tokens["id_token"] as? String { payload["id_token"] = i } + var u: [String: Any] = [:] + if let v = user["email"] as? String { u["email"] = v } + if let v = user["name"] as? String { u["name"] = v } + if let v = user["sub"] as? String { u["sub"] = v } + if let v = user["picture"] as? String { u["picture"] = v } + if let handle { u["handle"] = handle } + payload["user"] = u + + guard let data = try? JSONSerialization.data( + withJSONObject: payload, options: [.prettyPrinted]) else { return } + // Atomic write so ACSession's file-watch sees a single clean change. + try? data.write(to: ACSession.tokenURL, options: [.atomic]) + } + + // ── URLs / browser ──────────────────────────────────────────────── + private func authURL(state: String, challenge: String, forcePrompt: Bool) -> URL { + var c = URLComponents(string: "https://\(authDomain)/authorize")! + var items = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "scope", value: "openid profile email offline_access"), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + if forcePrompt { items.append(URLQueryItem(name: "prompt", value: "login")) } + c.queryItems = items + return c.url! + } + + private func openBrowser(_ url: URL) { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + + // ── browser-facing HTML (mirrors ac-login.mjs styling) ─────────────── + private static func successHTML(_ display: String) -> String { + """ + + + Logged In · Aesthetic Computer

✅ Login Successful

+

Welcome, \(display)!

You may close this page.

+ + """ + } + private static func failureHTML(_ error: String, _ desc: String) -> String { + """ + + + Login Failed · Aesthetic Computer

❌ Authentication Failed

+

\(error)

\(desc)

You may close this page.

+ """ + } +} + +// ── minimal loopback HTTP server (BSD sockets) ─────────────────────────── +// A single-connection-at-a-time HTTP/1.0 responder. Enough to receive Auth0's +// redirect GET on 127.0.0.1 and reply with a close-this-page page. No external +// deps; avoids pulling in Network.framework just for one request. +private final class SocketListener { + struct Request { let path: String; let query: [String: String] } + + var onRequest: ((Request, _ respond: @escaping (Int, String, String) -> Void) -> Void)? + + private let port: UInt16 + private var fd: Int32 = -1 + private let queue = DispatchQueue(label: "ac.login.socket") + private var running = false + + init(port: UInt16) throws { + self.port = port + fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw ACLogin.LoginError.serverFailed("socket()") } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.portInUse + } + guard listen(fd, 4) == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.serverFailed("listen()") + } + } + + func start() throws { + running = true + queue.async { [weak self] in self?.acceptLoop() } + } + + func stop() { + running = false + if fd >= 0 { close(fd); fd = -1 } + } + + private func acceptLoop() { + while running { + let client = accept(fd, nil, nil) + if client < 0 { break } + handle(client) + // One callback redirect is all we need; keep looping in case the + // browser retries (favicon, double-fetch) until stop() closes fd. + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + var buf = [UInt8](repeating: 0, count: 8192) + let n = read(client, &buf, buf.count) + guard n > 0 else { return } + let raw = String(decoding: buf[0..= 2 else { return } + let target = String(parts[1]) + + let comps = URLComponents(string: "http://localhost\(target)") + let path = comps?.path ?? target + var query: [String: String] = [:] + for item in comps?.queryItems ?? [] { query[item.name] = item.value } + + let request = Request(path: path, query: query) + var responded = false + let respond: (Int, String, String) -> Void = { status, contentType, body in + guard !responded else { return } + responded = true + let bytes = Array(body.utf8) + let header = "HTTP/1.0 \(status) \(status == 200 ? "OK" : "Error")\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(bytes.count)\r\n" + + "Connection: close\r\n\r\n" + let out = Array(header.utf8) + bytes + _ = out.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + } + + if let onRequest { + onRequest(request, respond) + } else { + respond(404, "text/plain", "Not found") + } + // Give async respond() (token exchange) time to flush before close. + if !responded { + let deadline = Date().addingTimeInterval(310) + while !responded && Date() < deadline && running { + usleep(50_000) + } + } + } +} diff --git a/shot-wizard/Sources/ShotWizard/ACLogin.swift b/shot-wizard/Sources/ShotWizard/ACLogin.swift new file mode 100644 index 0000000000..7d22300b6c --- /dev/null +++ b/shot-wizard/Sources/ShotWizard/ACLogin.swift @@ -0,0 +1,406 @@ +// ACLogin.swift — native, in-app AC sign-in for the macOS app suite. +// +// SOURCE OF TRUTH: shared/swift/ACLogin.swift. Like ACSession.swift, the +// standalone SwiftPM apps (date-wizard, wave-wizard, clip-wizard, juke-wizard, +// slab menubar, menuband) each keep a COPY in their own Sources/. When you +// change this file, re-copy it into each consumer: +// cp shared/swift/ACLogin.swift /Sources// +// +// This is a native port of `tezos/ac-login.mjs` — the same OAuth 2.0 +// Authorization-Code + PKCE flow against Auth0 (`hi.aesthetic.computer`) with a +// localhost loopback callback on port 44233. It writes the SAME ~/.ac-token +// JSON shape that ACSession.swift reads, so a sign-in here is indistinguishable +// from one done by the `ac-login` CLI — the whole suite picks it up live via +// the shared file-watch. No Terminal, no Node, no CLI dependency. +// +// ACLogin.shared.signIn { result in +// switch result { +// case .success(let handle): … // "@handle" (or email/name fallback) +// case .failure(let error): … +// } +// } +// +// The callback fires on the main queue. ACSession's file-watch will ALSO fire +// independently the instant the token lands, so UIs that already observe the +// session refresh on their own — the completion handler is for surfacing +// progress/errors on the sign-in screen itself. +import Foundation +import AppKit +import CryptoKit + +final class ACLogin { + static let shared = ACLogin() + + // ── config (must match ac-login.mjs / Auth0 allowed callback) ──────── + private let authDomain = "hi.aesthetic.computer" + private let clientID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt" + private let callbackPort: UInt16 = 44233 + private var redirectURI: String { "http://localhost:\(callbackPort)/callback" } + + enum LoginError: LocalizedError { + case portInUse + case serverFailed(String) + case stateMismatch + case tokenExchange(String) + case cancelled + case timeout + + var errorDescription: String? { + switch self { + case .portInUse: return "Port \(44233) is in use — close any running ac-login and retry." + case .serverFailed(let m): return "Sign-in server failed: \(m)" + case .stateMismatch: return "Sign-in state mismatch — please retry." + case .tokenExchange(let m): return "Token exchange failed: \(m)" + case .cancelled: return "Sign-in cancelled." + case .timeout: return "Sign-in timed out." + } + } + } + + private var listener: SocketListener? + private var inFlight = false + + /// True while a sign-in is waiting on the browser/callback. + var isSigningIn: Bool { inFlight } + + // ── PKCE ───────────────────────────────────────────────────────────── + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + private func randomURLToken(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) + return base64URL(Data(bytes)) + } + + // ── public entry ─────────────────────────────────────────────────── + /// Start the browser sign-in flow. `forcePrompt` adds `prompt=login` to + /// force the Auth0 account chooser (the CLI's `ac-login fresh`). + func signIn(forcePrompt: Bool = false, + completion: @escaping (Result) -> Void) { + if inFlight { listener?.stop(); inFlight = false } + + let verifier = randomURLToken(byteCount: 32) + let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8)))) + let state = randomURLToken(byteCount: 16) + + let finish: (Result) -> Void = { [weak self] result in + guard let self else { return } + self.inFlight = false + self.listener?.stop() + self.listener = nil + DispatchQueue.main.async { completion(result) } + } + + let server: SocketListener + do { + server = try SocketListener(port: callbackPort) + } catch { + finish(.failure(LoginError.portInUse)) + return + } + listener = server + inFlight = true + + // 5-minute safety timeout, matching the CLI. + DispatchQueue.global().asyncAfter(deadline: .now() + 300) { [weak self] in + guard let self, self.inFlight else { return } + finish(.failure(LoginError.timeout)) + } + + server.onRequest = { [weak self] req, respond in + guard let self else { return } + guard req.path.contains("callback") else { + respond(404, "text/plain", "Not found"); return + } + if let err = req.query["error"] { + let desc = req.query["error_description"] ?? "" + respond(200, "text/html; charset=utf-8", Self.failureHTML(err, desc)) + finish(.failure(LoginError.tokenExchange(desc.isEmpty ? err : desc))) + return + } + guard let code = req.query["code"] else { + respond(400, "text/plain", "Missing authorization code"); return + } + guard req.query["state"] == state else { + respond(400, "text/plain", "State mismatch") + finish(.failure(LoginError.stateMismatch)) + return + } + // Exchange code → tokens, fetch userinfo + handle, write ~/.ac-token. + self.exchangeAndStore(code: code, verifier: verifier) { result in + switch result { + case .success(let display): + respond(200, "text/html; charset=utf-8", Self.successHTML(display)) + finish(.success(display)) + case .failure(let error): + respond(500, "text/plain", "Error: \(error.localizedDescription)") + finish(.failure(error)) + } + } + } + + do { + try server.start() + } catch { + finish(.failure(LoginError.serverFailed("\(error)"))) + return + } + + openBrowser(authURL(state: state, challenge: challenge, forcePrompt: forcePrompt)) + } + + /// Stop a pending sign-in (e.g. user navigated away). + func cancel() { + guard inFlight else { return } + inFlight = false + listener?.stop() + listener = nil + } + + // ── token exchange + persistence ───────────────────────────────────── + private func exchangeAndStore(code: String, verifier: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/oauth/token")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "grant_type": "authorization_code", + "client_id": clientID, + "code_verifier": verifier, + "code": code, + "redirect_uri": redirectURI, + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in + guard let self else { return } + if let err { completion(.failure(err)); return } + guard let data, + let http = resp as? HTTPURLResponse, http.statusCode == 200, + let tokens = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let access = tokens["access_token"] as? String else { + let txt = data.flatMap { String(data: $0, encoding: .utf8) } ?? "unknown" + completion(.failure(LoginError.tokenExchange(txt))) + return + } + self.fetchUserAndWrite(tokens: tokens, access: access, completion: completion) + }.resume() + } + + private func fetchUserAndWrite(tokens: [String: Any], access: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/userinfo")!) + req.setValue("Bearer \(access)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { [weak self] data, _, _ in + guard let self else { return } + let user = (data.flatMap { + (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] + }) ?? [:] + let sub = user["sub"] as? String + self.fetchHandle(sub: sub) { handle in + self.writeToken(tokens: tokens, user: user, handle: handle) + ACSession.shared.broadcastChanged() + let display = handle.map { "@\($0)" } + ?? (user["email"] as? String) + ?? (user["name"] as? String) + ?? "signed in" + completion(.success(display)) + } + }.resume() + } + + private func fetchHandle(sub: String?, completion: @escaping (String?) -> Void) { + guard let sub, let encoded = sub.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://aesthetic.computer/handle?for=\(encoded)") else { + completion(nil); return + } + URLSession.shared.dataTask(with: url) { data, _, _ in + let handle = data.flatMap { + (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["handle"] as? String + } + completion(handle) + }.resume() + } + + private func writeToken(tokens: [String: Any], user: [String: Any], handle: String?) { + let expiresIn = (tokens["expires_in"] as? Double) + ?? (tokens["expires_in"] as? Int).map(Double.init) ?? 0 + var payload: [String: Any] = [ + "access_token": tokens["access_token"] as? String ?? "", + "expires_at": Date().timeIntervalSince1970 * 1000 + expiresIn * 1000, + ] + if let r = tokens["refresh_token"] as? String { payload["refresh_token"] = r } + if let i = tokens["id_token"] as? String { payload["id_token"] = i } + var u: [String: Any] = [:] + if let v = user["email"] as? String { u["email"] = v } + if let v = user["name"] as? String { u["name"] = v } + if let v = user["sub"] as? String { u["sub"] = v } + if let v = user["picture"] as? String { u["picture"] = v } + if let handle { u["handle"] = handle } + payload["user"] = u + + guard let data = try? JSONSerialization.data( + withJSONObject: payload, options: [.prettyPrinted]) else { return } + // Atomic write so ACSession's file-watch sees a single clean change. + try? data.write(to: ACSession.tokenURL, options: [.atomic]) + } + + // ── URLs / browser ──────────────────────────────────────────────── + private func authURL(state: String, challenge: String, forcePrompt: Bool) -> URL { + var c = URLComponents(string: "https://\(authDomain)/authorize")! + var items = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "scope", value: "openid profile email offline_access"), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + if forcePrompt { items.append(URLQueryItem(name: "prompt", value: "login")) } + c.queryItems = items + return c.url! + } + + private func openBrowser(_ url: URL) { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + + // ── browser-facing HTML (mirrors ac-login.mjs styling) ─────────────── + private static func successHTML(_ display: String) -> String { + """ + + + Logged In · Aesthetic Computer

✅ Login Successful

+

Welcome, \(display)!

You may close this page.

+ + """ + } + private static func failureHTML(_ error: String, _ desc: String) -> String { + """ + + + Login Failed · Aesthetic Computer

❌ Authentication Failed

+

\(error)

\(desc)

You may close this page.

+ """ + } +} + +// ── minimal loopback HTTP server (BSD sockets) ─────────────────────────── +// A single-connection-at-a-time HTTP/1.0 responder. Enough to receive Auth0's +// redirect GET on 127.0.0.1 and reply with a close-this-page page. No external +// deps; avoids pulling in Network.framework just for one request. +private final class SocketListener { + struct Request { let path: String; let query: [String: String] } + + var onRequest: ((Request, _ respond: @escaping (Int, String, String) -> Void) -> Void)? + + private let port: UInt16 + private var fd: Int32 = -1 + private let queue = DispatchQueue(label: "ac.login.socket") + private var running = false + + init(port: UInt16) throws { + self.port = port + fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw ACLogin.LoginError.serverFailed("socket()") } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.portInUse + } + guard listen(fd, 4) == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.serverFailed("listen()") + } + } + + func start() throws { + running = true + queue.async { [weak self] in self?.acceptLoop() } + } + + func stop() { + running = false + if fd >= 0 { close(fd); fd = -1 } + } + + private func acceptLoop() { + while running { + let client = accept(fd, nil, nil) + if client < 0 { break } + handle(client) + // One callback redirect is all we need; keep looping in case the + // browser retries (favicon, double-fetch) until stop() closes fd. + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + var buf = [UInt8](repeating: 0, count: 8192) + let n = read(client, &buf, buf.count) + guard n > 0 else { return } + let raw = String(decoding: buf[0..= 2 else { return } + let target = String(parts[1]) + + let comps = URLComponents(string: "http://localhost\(target)") + let path = comps?.path ?? target + var query: [String: String] = [:] + for item in comps?.queryItems ?? [] { query[item.name] = item.value } + + let request = Request(path: path, query: query) + var responded = false + let respond: (Int, String, String) -> Void = { status, contentType, body in + guard !responded else { return } + responded = true + let bytes = Array(body.utf8) + let header = "HTTP/1.0 \(status) \(status == 200 ? "OK" : "Error")\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(bytes.count)\r\n" + + "Connection: close\r\n\r\n" + let out = Array(header.utf8) + bytes + _ = out.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + } + + if let onRequest { + onRequest(request, respond) + } else { + respond(404, "text/plain", "Not found") + } + // Give async respond() (token exchange) time to flush before close. + if !responded { + let deadline = Date().addingTimeInterval(310) + while !responded && Date() < deadline && running { + usleep(50_000) + } + } + } +} diff --git a/shot-wizard/Sources/ShotWizard/ACSession.swift b/shot-wizard/Sources/ShotWizard/ACSession.swift new file mode 100644 index 0000000000..26551e49cf --- /dev/null +++ b/shot-wizard/Sources/ShotWizard/ACSession.swift @@ -0,0 +1,206 @@ +// 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 + } + + /// 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/menubar-swift/Sources/SlabMenubar/ACLogin.swift b/slab/menubar-swift/Sources/SlabMenubar/ACLogin.swift new file mode 100644 index 0000000000..7d22300b6c --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ACLogin.swift @@ -0,0 +1,406 @@ +// ACLogin.swift — native, in-app AC sign-in for the macOS app suite. +// +// SOURCE OF TRUTH: shared/swift/ACLogin.swift. Like ACSession.swift, the +// standalone SwiftPM apps (date-wizard, wave-wizard, clip-wizard, juke-wizard, +// slab menubar, menuband) each keep a COPY in their own Sources/. When you +// change this file, re-copy it into each consumer: +// cp shared/swift/ACLogin.swift /Sources// +// +// This is a native port of `tezos/ac-login.mjs` — the same OAuth 2.0 +// Authorization-Code + PKCE flow against Auth0 (`hi.aesthetic.computer`) with a +// localhost loopback callback on port 44233. It writes the SAME ~/.ac-token +// JSON shape that ACSession.swift reads, so a sign-in here is indistinguishable +// from one done by the `ac-login` CLI — the whole suite picks it up live via +// the shared file-watch. No Terminal, no Node, no CLI dependency. +// +// ACLogin.shared.signIn { result in +// switch result { +// case .success(let handle): … // "@handle" (or email/name fallback) +// case .failure(let error): … +// } +// } +// +// The callback fires on the main queue. ACSession's file-watch will ALSO fire +// independently the instant the token lands, so UIs that already observe the +// session refresh on their own — the completion handler is for surfacing +// progress/errors on the sign-in screen itself. +import Foundation +import AppKit +import CryptoKit + +final class ACLogin { + static let shared = ACLogin() + + // ── config (must match ac-login.mjs / Auth0 allowed callback) ──────── + private let authDomain = "hi.aesthetic.computer" + private let clientID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt" + private let callbackPort: UInt16 = 44233 + private var redirectURI: String { "http://localhost:\(callbackPort)/callback" } + + enum LoginError: LocalizedError { + case portInUse + case serverFailed(String) + case stateMismatch + case tokenExchange(String) + case cancelled + case timeout + + var errorDescription: String? { + switch self { + case .portInUse: return "Port \(44233) is in use — close any running ac-login and retry." + case .serverFailed(let m): return "Sign-in server failed: \(m)" + case .stateMismatch: return "Sign-in state mismatch — please retry." + case .tokenExchange(let m): return "Token exchange failed: \(m)" + case .cancelled: return "Sign-in cancelled." + case .timeout: return "Sign-in timed out." + } + } + } + + private var listener: SocketListener? + private var inFlight = false + + /// True while a sign-in is waiting on the browser/callback. + var isSigningIn: Bool { inFlight } + + // ── PKCE ───────────────────────────────────────────────────────────── + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + private func randomURLToken(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) + return base64URL(Data(bytes)) + } + + // ── public entry ─────────────────────────────────────────────────── + /// Start the browser sign-in flow. `forcePrompt` adds `prompt=login` to + /// force the Auth0 account chooser (the CLI's `ac-login fresh`). + func signIn(forcePrompt: Bool = false, + completion: @escaping (Result) -> Void) { + if inFlight { listener?.stop(); inFlight = false } + + let verifier = randomURLToken(byteCount: 32) + let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8)))) + let state = randomURLToken(byteCount: 16) + + let finish: (Result) -> Void = { [weak self] result in + guard let self else { return } + self.inFlight = false + self.listener?.stop() + self.listener = nil + DispatchQueue.main.async { completion(result) } + } + + let server: SocketListener + do { + server = try SocketListener(port: callbackPort) + } catch { + finish(.failure(LoginError.portInUse)) + return + } + listener = server + inFlight = true + + // 5-minute safety timeout, matching the CLI. + DispatchQueue.global().asyncAfter(deadline: .now() + 300) { [weak self] in + guard let self, self.inFlight else { return } + finish(.failure(LoginError.timeout)) + } + + server.onRequest = { [weak self] req, respond in + guard let self else { return } + guard req.path.contains("callback") else { + respond(404, "text/plain", "Not found"); return + } + if let err = req.query["error"] { + let desc = req.query["error_description"] ?? "" + respond(200, "text/html; charset=utf-8", Self.failureHTML(err, desc)) + finish(.failure(LoginError.tokenExchange(desc.isEmpty ? err : desc))) + return + } + guard let code = req.query["code"] else { + respond(400, "text/plain", "Missing authorization code"); return + } + guard req.query["state"] == state else { + respond(400, "text/plain", "State mismatch") + finish(.failure(LoginError.stateMismatch)) + return + } + // Exchange code → tokens, fetch userinfo + handle, write ~/.ac-token. + self.exchangeAndStore(code: code, verifier: verifier) { result in + switch result { + case .success(let display): + respond(200, "text/html; charset=utf-8", Self.successHTML(display)) + finish(.success(display)) + case .failure(let error): + respond(500, "text/plain", "Error: \(error.localizedDescription)") + finish(.failure(error)) + } + } + } + + do { + try server.start() + } catch { + finish(.failure(LoginError.serverFailed("\(error)"))) + return + } + + openBrowser(authURL(state: state, challenge: challenge, forcePrompt: forcePrompt)) + } + + /// Stop a pending sign-in (e.g. user navigated away). + func cancel() { + guard inFlight else { return } + inFlight = false + listener?.stop() + listener = nil + } + + // ── token exchange + persistence ───────────────────────────────────── + private func exchangeAndStore(code: String, verifier: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/oauth/token")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "grant_type": "authorization_code", + "client_id": clientID, + "code_verifier": verifier, + "code": code, + "redirect_uri": redirectURI, + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in + guard let self else { return } + if let err { completion(.failure(err)); return } + guard let data, + let http = resp as? HTTPURLResponse, http.statusCode == 200, + let tokens = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let access = tokens["access_token"] as? String else { + let txt = data.flatMap { String(data: $0, encoding: .utf8) } ?? "unknown" + completion(.failure(LoginError.tokenExchange(txt))) + return + } + self.fetchUserAndWrite(tokens: tokens, access: access, completion: completion) + }.resume() + } + + private func fetchUserAndWrite(tokens: [String: Any], access: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/userinfo")!) + req.setValue("Bearer \(access)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { [weak self] data, _, _ in + guard let self else { return } + let user = (data.flatMap { + (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] + }) ?? [:] + let sub = user["sub"] as? String + self.fetchHandle(sub: sub) { handle in + self.writeToken(tokens: tokens, user: user, handle: handle) + ACSession.shared.broadcastChanged() + let display = handle.map { "@\($0)" } + ?? (user["email"] as? String) + ?? (user["name"] as? String) + ?? "signed in" + completion(.success(display)) + } + }.resume() + } + + private func fetchHandle(sub: String?, completion: @escaping (String?) -> Void) { + guard let sub, let encoded = sub.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://aesthetic.computer/handle?for=\(encoded)") else { + completion(nil); return + } + URLSession.shared.dataTask(with: url) { data, _, _ in + let handle = data.flatMap { + (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["handle"] as? String + } + completion(handle) + }.resume() + } + + private func writeToken(tokens: [String: Any], user: [String: Any], handle: String?) { + let expiresIn = (tokens["expires_in"] as? Double) + ?? (tokens["expires_in"] as? Int).map(Double.init) ?? 0 + var payload: [String: Any] = [ + "access_token": tokens["access_token"] as? String ?? "", + "expires_at": Date().timeIntervalSince1970 * 1000 + expiresIn * 1000, + ] + if let r = tokens["refresh_token"] as? String { payload["refresh_token"] = r } + if let i = tokens["id_token"] as? String { payload["id_token"] = i } + var u: [String: Any] = [:] + if let v = user["email"] as? String { u["email"] = v } + if let v = user["name"] as? String { u["name"] = v } + if let v = user["sub"] as? String { u["sub"] = v } + if let v = user["picture"] as? String { u["picture"] = v } + if let handle { u["handle"] = handle } + payload["user"] = u + + guard let data = try? JSONSerialization.data( + withJSONObject: payload, options: [.prettyPrinted]) else { return } + // Atomic write so ACSession's file-watch sees a single clean change. + try? data.write(to: ACSession.tokenURL, options: [.atomic]) + } + + // ── URLs / browser ──────────────────────────────────────────────── + private func authURL(state: String, challenge: String, forcePrompt: Bool) -> URL { + var c = URLComponents(string: "https://\(authDomain)/authorize")! + var items = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "scope", value: "openid profile email offline_access"), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + if forcePrompt { items.append(URLQueryItem(name: "prompt", value: "login")) } + c.queryItems = items + return c.url! + } + + private func openBrowser(_ url: URL) { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + + // ── browser-facing HTML (mirrors ac-login.mjs styling) ─────────────── + private static func successHTML(_ display: String) -> String { + """ + + + Logged In · Aesthetic Computer

✅ Login Successful

+

Welcome, \(display)!

You may close this page.

+ + """ + } + private static func failureHTML(_ error: String, _ desc: String) -> String { + """ + + + Login Failed · Aesthetic Computer

❌ Authentication Failed

+

\(error)

\(desc)

You may close this page.

+ """ + } +} + +// ── minimal loopback HTTP server (BSD sockets) ─────────────────────────── +// A single-connection-at-a-time HTTP/1.0 responder. Enough to receive Auth0's +// redirect GET on 127.0.0.1 and reply with a close-this-page page. No external +// deps; avoids pulling in Network.framework just for one request. +private final class SocketListener { + struct Request { let path: String; let query: [String: String] } + + var onRequest: ((Request, _ respond: @escaping (Int, String, String) -> Void) -> Void)? + + private let port: UInt16 + private var fd: Int32 = -1 + private let queue = DispatchQueue(label: "ac.login.socket") + private var running = false + + init(port: UInt16) throws { + self.port = port + fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw ACLogin.LoginError.serverFailed("socket()") } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.portInUse + } + guard listen(fd, 4) == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.serverFailed("listen()") + } + } + + func start() throws { + running = true + queue.async { [weak self] in self?.acceptLoop() } + } + + func stop() { + running = false + if fd >= 0 { close(fd); fd = -1 } + } + + private func acceptLoop() { + while running { + let client = accept(fd, nil, nil) + if client < 0 { break } + handle(client) + // One callback redirect is all we need; keep looping in case the + // browser retries (favicon, double-fetch) until stop() closes fd. + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + var buf = [UInt8](repeating: 0, count: 8192) + let n = read(client, &buf, buf.count) + guard n > 0 else { return } + let raw = String(decoding: buf[0..= 2 else { return } + let target = String(parts[1]) + + let comps = URLComponents(string: "http://localhost\(target)") + let path = comps?.path ?? target + var query: [String: String] = [:] + for item in comps?.queryItems ?? [] { query[item.name] = item.value } + + let request = Request(path: path, query: query) + var responded = false + let respond: (Int, String, String) -> Void = { status, contentType, body in + guard !responded else { return } + responded = true + let bytes = Array(body.utf8) + let header = "HTTP/1.0 \(status) \(status == 200 ? "OK" : "Error")\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(bytes.count)\r\n" + + "Connection: close\r\n\r\n" + let out = Array(header.utf8) + bytes + _ = out.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + } + + if let onRequest { + onRequest(request, respond) + } else { + respond(404, "text/plain", "Not found") + } + // Give async respond() (token exchange) time to flush before close. + if !responded { + let deadline = Date().addingTimeInterval(310) + while !responded && Date() < deadline && running { + usleep(50_000) + } + } + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/ACSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ACSession.swift new file mode 100644 index 0000000000..26551e49cf --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ACSession.swift @@ -0,0 +1,206 @@ +// 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 + } + + /// 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/wave-wizard/Sources/WaveWizard/ACLogin.swift b/wave-wizard/Sources/WaveWizard/ACLogin.swift new file mode 100644 index 0000000000..7d22300b6c --- /dev/null +++ b/wave-wizard/Sources/WaveWizard/ACLogin.swift @@ -0,0 +1,406 @@ +// ACLogin.swift — native, in-app AC sign-in for the macOS app suite. +// +// SOURCE OF TRUTH: shared/swift/ACLogin.swift. Like ACSession.swift, the +// standalone SwiftPM apps (date-wizard, wave-wizard, clip-wizard, juke-wizard, +// slab menubar, menuband) each keep a COPY in their own Sources/. When you +// change this file, re-copy it into each consumer: +// cp shared/swift/ACLogin.swift /Sources// +// +// This is a native port of `tezos/ac-login.mjs` — the same OAuth 2.0 +// Authorization-Code + PKCE flow against Auth0 (`hi.aesthetic.computer`) with a +// localhost loopback callback on port 44233. It writes the SAME ~/.ac-token +// JSON shape that ACSession.swift reads, so a sign-in here is indistinguishable +// from one done by the `ac-login` CLI — the whole suite picks it up live via +// the shared file-watch. No Terminal, no Node, no CLI dependency. +// +// ACLogin.shared.signIn { result in +// switch result { +// case .success(let handle): … // "@handle" (or email/name fallback) +// case .failure(let error): … +// } +// } +// +// The callback fires on the main queue. ACSession's file-watch will ALSO fire +// independently the instant the token lands, so UIs that already observe the +// session refresh on their own — the completion handler is for surfacing +// progress/errors on the sign-in screen itself. +import Foundation +import AppKit +import CryptoKit + +final class ACLogin { + static let shared = ACLogin() + + // ── config (must match ac-login.mjs / Auth0 allowed callback) ──────── + private let authDomain = "hi.aesthetic.computer" + private let clientID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt" + private let callbackPort: UInt16 = 44233 + private var redirectURI: String { "http://localhost:\(callbackPort)/callback" } + + enum LoginError: LocalizedError { + case portInUse + case serverFailed(String) + case stateMismatch + case tokenExchange(String) + case cancelled + case timeout + + var errorDescription: String? { + switch self { + case .portInUse: return "Port \(44233) is in use — close any running ac-login and retry." + case .serverFailed(let m): return "Sign-in server failed: \(m)" + case .stateMismatch: return "Sign-in state mismatch — please retry." + case .tokenExchange(let m): return "Token exchange failed: \(m)" + case .cancelled: return "Sign-in cancelled." + case .timeout: return "Sign-in timed out." + } + } + } + + private var listener: SocketListener? + private var inFlight = false + + /// True while a sign-in is waiting on the browser/callback. + var isSigningIn: Bool { inFlight } + + // ── PKCE ───────────────────────────────────────────────────────────── + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + private func randomURLToken(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) + return base64URL(Data(bytes)) + } + + // ── public entry ─────────────────────────────────────────────────── + /// Start the browser sign-in flow. `forcePrompt` adds `prompt=login` to + /// force the Auth0 account chooser (the CLI's `ac-login fresh`). + func signIn(forcePrompt: Bool = false, + completion: @escaping (Result) -> Void) { + if inFlight { listener?.stop(); inFlight = false } + + let verifier = randomURLToken(byteCount: 32) + let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8)))) + let state = randomURLToken(byteCount: 16) + + let finish: (Result) -> Void = { [weak self] result in + guard let self else { return } + self.inFlight = false + self.listener?.stop() + self.listener = nil + DispatchQueue.main.async { completion(result) } + } + + let server: SocketListener + do { + server = try SocketListener(port: callbackPort) + } catch { + finish(.failure(LoginError.portInUse)) + return + } + listener = server + inFlight = true + + // 5-minute safety timeout, matching the CLI. + DispatchQueue.global().asyncAfter(deadline: .now() + 300) { [weak self] in + guard let self, self.inFlight else { return } + finish(.failure(LoginError.timeout)) + } + + server.onRequest = { [weak self] req, respond in + guard let self else { return } + guard req.path.contains("callback") else { + respond(404, "text/plain", "Not found"); return + } + if let err = req.query["error"] { + let desc = req.query["error_description"] ?? "" + respond(200, "text/html; charset=utf-8", Self.failureHTML(err, desc)) + finish(.failure(LoginError.tokenExchange(desc.isEmpty ? err : desc))) + return + } + guard let code = req.query["code"] else { + respond(400, "text/plain", "Missing authorization code"); return + } + guard req.query["state"] == state else { + respond(400, "text/plain", "State mismatch") + finish(.failure(LoginError.stateMismatch)) + return + } + // Exchange code → tokens, fetch userinfo + handle, write ~/.ac-token. + self.exchangeAndStore(code: code, verifier: verifier) { result in + switch result { + case .success(let display): + respond(200, "text/html; charset=utf-8", Self.successHTML(display)) + finish(.success(display)) + case .failure(let error): + respond(500, "text/plain", "Error: \(error.localizedDescription)") + finish(.failure(error)) + } + } + } + + do { + try server.start() + } catch { + finish(.failure(LoginError.serverFailed("\(error)"))) + return + } + + openBrowser(authURL(state: state, challenge: challenge, forcePrompt: forcePrompt)) + } + + /// Stop a pending sign-in (e.g. user navigated away). + func cancel() { + guard inFlight else { return } + inFlight = false + listener?.stop() + listener = nil + } + + // ── token exchange + persistence ───────────────────────────────────── + private func exchangeAndStore(code: String, verifier: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/oauth/token")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "grant_type": "authorization_code", + "client_id": clientID, + "code_verifier": verifier, + "code": code, + "redirect_uri": redirectURI, + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in + guard let self else { return } + if let err { completion(.failure(err)); return } + guard let data, + let http = resp as? HTTPURLResponse, http.statusCode == 200, + let tokens = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let access = tokens["access_token"] as? String else { + let txt = data.flatMap { String(data: $0, encoding: .utf8) } ?? "unknown" + completion(.failure(LoginError.tokenExchange(txt))) + return + } + self.fetchUserAndWrite(tokens: tokens, access: access, completion: completion) + }.resume() + } + + private func fetchUserAndWrite(tokens: [String: Any], access: String, + completion: @escaping (Result) -> Void) { + var req = URLRequest(url: URL(string: "https://\(authDomain)/userinfo")!) + req.setValue("Bearer \(access)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { [weak self] data, _, _ in + guard let self else { return } + let user = (data.flatMap { + (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] + }) ?? [:] + let sub = user["sub"] as? String + self.fetchHandle(sub: sub) { handle in + self.writeToken(tokens: tokens, user: user, handle: handle) + ACSession.shared.broadcastChanged() + let display = handle.map { "@\($0)" } + ?? (user["email"] as? String) + ?? (user["name"] as? String) + ?? "signed in" + completion(.success(display)) + } + }.resume() + } + + private func fetchHandle(sub: String?, completion: @escaping (String?) -> Void) { + guard let sub, let encoded = sub.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://aesthetic.computer/handle?for=\(encoded)") else { + completion(nil); return + } + URLSession.shared.dataTask(with: url) { data, _, _ in + let handle = data.flatMap { + (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["handle"] as? String + } + completion(handle) + }.resume() + } + + private func writeToken(tokens: [String: Any], user: [String: Any], handle: String?) { + let expiresIn = (tokens["expires_in"] as? Double) + ?? (tokens["expires_in"] as? Int).map(Double.init) ?? 0 + var payload: [String: Any] = [ + "access_token": tokens["access_token"] as? String ?? "", + "expires_at": Date().timeIntervalSince1970 * 1000 + expiresIn * 1000, + ] + if let r = tokens["refresh_token"] as? String { payload["refresh_token"] = r } + if let i = tokens["id_token"] as? String { payload["id_token"] = i } + var u: [String: Any] = [:] + if let v = user["email"] as? String { u["email"] = v } + if let v = user["name"] as? String { u["name"] = v } + if let v = user["sub"] as? String { u["sub"] = v } + if let v = user["picture"] as? String { u["picture"] = v } + if let handle { u["handle"] = handle } + payload["user"] = u + + guard let data = try? JSONSerialization.data( + withJSONObject: payload, options: [.prettyPrinted]) else { return } + // Atomic write so ACSession's file-watch sees a single clean change. + try? data.write(to: ACSession.tokenURL, options: [.atomic]) + } + + // ── URLs / browser ──────────────────────────────────────────────── + private func authURL(state: String, challenge: String, forcePrompt: Bool) -> URL { + var c = URLComponents(string: "https://\(authDomain)/authorize")! + var items = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "scope", value: "openid profile email offline_access"), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + if forcePrompt { items.append(URLQueryItem(name: "prompt", value: "login")) } + c.queryItems = items + return c.url! + } + + private func openBrowser(_ url: URL) { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + + // ── browser-facing HTML (mirrors ac-login.mjs styling) ─────────────── + private static func successHTML(_ display: String) -> String { + """ + + + Logged In · Aesthetic Computer

✅ Login Successful

+

Welcome, \(display)!

You may close this page.

+ + """ + } + private static func failureHTML(_ error: String, _ desc: String) -> String { + """ + + + Login Failed · Aesthetic Computer

❌ Authentication Failed

+

\(error)

\(desc)

You may close this page.

+ """ + } +} + +// ── minimal loopback HTTP server (BSD sockets) ─────────────────────────── +// A single-connection-at-a-time HTTP/1.0 responder. Enough to receive Auth0's +// redirect GET on 127.0.0.1 and reply with a close-this-page page. No external +// deps; avoids pulling in Network.framework just for one request. +private final class SocketListener { + struct Request { let path: String; let query: [String: String] } + + var onRequest: ((Request, _ respond: @escaping (Int, String, String) -> Void) -> Void)? + + private let port: UInt16 + private var fd: Int32 = -1 + private let queue = DispatchQueue(label: "ac.login.socket") + private var running = false + + init(port: UInt16) throws { + self.port = port + fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw ACLogin.LoginError.serverFailed("socket()") } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.portInUse + } + guard listen(fd, 4) == 0 else { + close(fd); fd = -1 + throw ACLogin.LoginError.serverFailed("listen()") + } + } + + func start() throws { + running = true + queue.async { [weak self] in self?.acceptLoop() } + } + + func stop() { + running = false + if fd >= 0 { close(fd); fd = -1 } + } + + private func acceptLoop() { + while running { + let client = accept(fd, nil, nil) + if client < 0 { break } + handle(client) + // One callback redirect is all we need; keep looping in case the + // browser retries (favicon, double-fetch) until stop() closes fd. + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + var buf = [UInt8](repeating: 0, count: 8192) + let n = read(client, &buf, buf.count) + guard n > 0 else { return } + let raw = String(decoding: buf[0..= 2 else { return } + let target = String(parts[1]) + + let comps = URLComponents(string: "http://localhost\(target)") + let path = comps?.path ?? target + var query: [String: String] = [:] + for item in comps?.queryItems ?? [] { query[item.name] = item.value } + + let request = Request(path: path, query: query) + var responded = false + let respond: (Int, String, String) -> Void = { status, contentType, body in + guard !responded else { return } + responded = true + let bytes = Array(body.utf8) + let header = "HTTP/1.0 \(status) \(status == 200 ? "OK" : "Error")\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(bytes.count)\r\n" + + "Connection: close\r\n\r\n" + let out = Array(header.utf8) + bytes + _ = out.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + } + + if let onRequest { + onRequest(request, respond) + } else { + respond(404, "text/plain", "Not found") + } + // Give async respond() (token exchange) time to flush before close. + if !responded { + let deadline = Date().addingTimeInterval(310) + while !responded && Date() < deadline && running { + usleep(50_000) + } + } + } +} diff --git a/wave-wizard/Sources/WaveWizard/ACSession.swift b/wave-wizard/Sources/WaveWizard/ACSession.swift new file mode 100644 index 0000000000..26551e49cf --- /dev/null +++ b/wave-wizard/Sources/WaveWizard/ACSession.swift @@ -0,0 +1,206 @@ +// 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 + } + + /// 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() + } +}