diff --git a/.gitignore b/.gitignore index e400ab0..c692b22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # AI .codex .decodex +!apps/decodex-app/.codex/ +!apps/decodex-app/.codex/environments/ +!apps/decodex-app/.codex/environments/environment.toml # Editor .vscode diff --git a/Cargo.toml b/Cargo.toml index 4396f45..811a9ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +exclude = ["apps/decodex-app"] members = ["apps/*"] resolver = "3" diff --git a/Makefile.toml b/Makefile.toml index 46f2799..be93566 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -201,10 +201,11 @@ args = [ ] # Test -# | task | type | cwd | -# | --------- | --------- | --- | -# | test | composite | | -# | test-rust | command | | +# | task | type | cwd | +# | ----------------------- | --------- | --- | +# | test | composite | | +# | test-rust | command | | +# | test-decodex-app-stage | script | | [tasks.test] clear = true @@ -224,6 +225,35 @@ args = [ "--all-features", ] +[tasks.test-decodex-app-stage] +workspace = false +script = ''' +if [ "$(uname -s)" != "Darwin" ]; then + echo "Decodex App staging is macOS-only; skipping." + exit 0 +fi + +./apps/decodex-app/script/build_and_run.sh stage +COMMON_ROOT="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)" +STAGE_DIR="${DECODEX_APP_STAGE_DIR:-$COMMON_ROOT/target/decodex-app}" +APP_PATH="$STAGE_DIR/Decodex App.app" +test -d "$APP_PATH" +test -x "$APP_PATH/Contents/MacOS/DecodexApp" +test -x "$APP_PATH/Contents/Helpers/decodex-app-helper" +test -f "$APP_PATH/Contents/Info.plist" +test -f "$APP_PATH/Contents/Resources/AppIcon.icns" +test -f "$APP_PATH/Contents/Resources/StatusBarIcon.png" +codesign --verify --deep --strict "$APP_PATH" +codesign --verify --strict "$APP_PATH/Contents/Helpers/decodex-app-helper" +codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -q '^TeamIdentifier=' +codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -q 'flags=.*runtime' +plutil -extract CFBundleName raw "$APP_PATH/Contents/Info.plist" | grep -qx 'Decodex App' +plutil -extract CFBundleDisplayName raw "$APP_PATH/Contents/Info.plist" | grep -qx 'Decodex App' +plutil -extract CFBundleIconFile raw "$APP_PATH/Contents/Info.plist" | grep -qx 'AppIcon' +plutil -extract CFBundleIdentifier raw "$APP_PATH/Contents/Info.plist" | grep -qx 'space.decodex.app' +plutil -extract LSUIElement raw "$APP_PATH/Contents/Info.plist" | grep -qx 'true' +''' + # Build # | task | type | cwd | # | ---------- | ------- | ---- | diff --git a/README.md b/README.md index ebfea99..d76af2f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Repo-native agent orchestration and public Codex signal publishing. ## Feature Highlights - Rust CLI and runtime for repo-native retained coding-agent lanes. +- Native macOS app for Decodex Codex account-pool management. - Explicit project registry under `~/.codex/decodex/projects//`. - Local operator listener with a dashboard at `/` and `/dashboard`, WebSocket snapshot/control traffic at `/dashboard/control`, and `GET /livez` for liveness. @@ -55,6 +56,8 @@ runtime. ## Workspace posture - `apps/decodex/` owns the Rust package that builds the `decodex` CLI and runtime. +- `apps/decodex-app/` owns the native macOS app that manages Decodex + Codex accounts through the bundled Rust app helper. - `site/` owns the Astro static site and checked-in public content. - `scripts/github/` owns deterministic GitHub bundle, release-delta, render, and validation scripts. @@ -127,7 +130,10 @@ file, and project configs do not own an account-pool path override. Set `[codex.accounts].fixed_account` in `~/.codex/decodex/config.toml` to pin all new account-pool runs to one account. When that global selector is absent, Decodex balances new runs across the pool. The operator dashboard Accounts UI writes and clears the same -global selector; project configs do not pin specific accounts. +global selector; project configs do not pin specific accounts. To switch the account +used by the Codex CLI itself, run `decodex account use ` or use the Decodex +App row action; this overwrites `$CODEX_HOME/auth.json` or `~/.codex/auth.json` from +the matching `accounts.jsonl` entry. `decodex diagnose --json` writes the local agent evidence index under `~/.codex/decodex/agent-evidence//` and prints the same handoff index for @@ -247,8 +253,8 @@ The tracked workspace currently keeps: - `docs/plans/` as historical saved plan artifacts from the static-site bootstrap - `dev/` as local development helpers outside `dev/skills/`, such as the operator dashboard mock server -- `assets/` as shared static assets that are not owned by the Astro app's generated - output +- `assets/` as generated Decodex App icon source notes, Icon Composer foreground, + generated `.icns`, and menu bar template assets - `.github/` as CI, release, Pages deployment, and content-refresh workflows Generated or local-only directories such as `target/`, `site/dist/`, `site/.astro/`, diff --git a/apps/decodex-app/.codex/environments/environment.toml b/apps/decodex-app/.codex/environments/environment.toml new file mode 100644 index 0000000..f96f57b --- /dev/null +++ b/apps/decodex-app/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +name = "Decodex App" +version = 1 + +[setup] +script = "" + +[[actions]] +command = "./script/build_and_run.sh" +icon = "run" +name = "Run" diff --git a/apps/decodex-app/Package.swift b/apps/decodex-app/Package.swift new file mode 100644 index 0000000..9a2e8b3 --- /dev/null +++ b/apps/decodex-app/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "DecodexApp", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "DecodexApp", targets: ["DecodexApp"]), + ], + targets: [ + .executableTarget(name: "DecodexApp"), + ], +) diff --git a/apps/decodex-app/README.md b/apps/decodex-app/README.md new file mode 100644 index 0000000..22e09d5 --- /dev/null +++ b/apps/decodex-app/README.md @@ -0,0 +1,76 @@ +# Decodex App + +Purpose: Native macOS app for the local Decodex account pool. + +Read this when: You are building or running the first Decodex desktop UI surface. + +Not this document: Runtime scheduling, retained-lane orchestration, public site behavior, +or the full operator dashboard. + +## Scope + +The first Decodex App release only manages the shared Codex account pool through the +bundled `decodex-app-helper`, which links the Rust account service directly: + +- list accounts without printing token material +- pin future Decodex runs to one account +- return future Decodex runs to balanced selection +- force Codex itself to use a stored account +- run isolated Codex device login, then import the resulting auth file +- remove a stored account from the local pool + +The app does not schedule Decodex runs, own project registration, or replace +`decodex serve`. It is a native UI over the shared Rust account-management service, +not a wrapper around the `decodex` CLI binary. + +## Development + +Build the SwiftPM app: + +```sh +swift build --package-path apps/decodex-app +``` + +Run it as a local `.app` bundle: + +```sh +apps/decodex-app/script/build_and_run.sh +``` + +Stage a signed bundle without launching it: + +```sh +apps/decodex-app/script/build_and_run.sh stage +cargo make test-decodex-app-stage +``` + +The staging script builds both the Swift app and the Rust `decodex-app-helper`, then +copies the helper into `Contents/Helpers/`. Direct SwiftPM launches are development-only; +when needed, point them at a workspace-built helper: + +```sh +cargo build -p decodex --bin decodex-app-helper +DECODEX_APP_HELPER="$(pwd)/target/debug/decodex-app-helper" swift run --package-path apps/decodex-app DecodexApp +``` + +The staging script follows the local Rsnap-style signing path: it writes +`target/decodex-app/Decodex App.app`, signs the bundle with an Apple Development +identity, enables hardened runtime, and verifies the signature before launch. Override +the signing identity with `DECODEX_APP_SIGN_IDENTITY`; override the staging directory +with `DECODEX_APP_STAGE_DIR`. This is local development signing, not a notarized +distribution build. + +The "Use in Codex" action overwrites Codex's `auth.json` from one stored +`~/.codex/decodex/accounts.jsonl` entry. The destination is `$CODEX_HOME/auth.json` +when `CODEX_HOME` is set, otherwise `~/.codex/auth.json`. + +App icon assets live under `assets/app-icon/` with `source/`, `composer/`, and +`generated/` lanes. Menu bar icon assets live under `assets/tray-icon/` with matching +`source/` and `generated/` lanes. Regenerate the full icon set with: + +```sh +scripts/assets/render_decodex_app_icons.swift +``` + +The staging script copies `app-icon.icns`, the template status item image, and the +signed `decodex-app-helper` into the app bundle. diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift new file mode 100644 index 0000000..109f10a --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -0,0 +1,260 @@ +import SwiftUI + +struct AccountPanelView: View { + @ObservedObject var store: AccountStore + @State private var pendingLogout: CodexAccount? + @State private var loginPresented = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + header + + if let notice = store.notice { + NoticeView(text: notice) + } + + if store.accounts.isEmpty { + emptyState + } else { + accountList + } + + Divider() + + footer + } + .frame(width: 420) + .padding(16) + .background(.regularMaterial) + .confirmationDialog( + "Remove account?", + isPresented: Binding( + get: { pendingLogout != nil }, + set: { visible in + if !visible { + pendingLogout = nil + } + } + ), + titleVisibility: .visible + ) { + if let account = pendingLogout { + Button("Log Out \(account.displayName)", role: .destructive) { + Task { + await store.logout(account) + } + } + } + } message: { + if let account = pendingLogout { + Text("This removes \(account.displayName) from the Decodex account pool on this Mac.") + } + } + .sheet(isPresented: $loginPresented) { + LoginSheetView(store: store) + } + } + + private var header: some View { + HStack(alignment: .center, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.quaternary) + Image(systemName: "person.2.circle.fill") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.primary) + } + .frame(width: 42, height: 42) + + VStack(alignment: .leading, spacing: 3) { + Text("Decodex Accounts") + .font(.headline) + Text(store.modeLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Button { + Task { + await store.refresh() + } + } label: { + Image(systemName: store.isRefreshing ? "arrow.triangle.2.circlepath.circle" : "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Refresh") + .disabled(store.isRefreshing) + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 10) { + Image(systemName: "person.crop.circle.badge.plus") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text("No accounts in the local pool") + .font(.subheadline.weight(.semibold)) + Text("Add a ChatGPT login before switching the Codex auth file.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var accountList: some View { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(store.accounts) { account in + AccountRowView( + account: account, + useInCodex: { + Task { + await store.useInCodex(account) + } + }, + pinForDecodex: { + Task { + await store.select(account) + } + }, + logout: { + pendingLogout = account + } + ) + } + } + .padding(.vertical, 1) + } + .frame(maxHeight: 340) + } + + private var footer: some View { + HStack(spacing: 10) { + Button { + loginPresented = true + } label: { + Label("Add Login", systemImage: "plus.circle") + } + + Button { + Task { + await store.clearSelection() + } + } label: { + Label("Balanced", systemImage: "arrow.triangle.branch") + } + .disabled(store.accounts.isEmpty) + + Spacer() + + SettingsLink { + Image(systemName: "gearshape") + } + .help("Settings") + } + } +} + +struct AccountRowView: View { + let account: CodexAccount + let useInCodex: () -> Void + let pinForDecodex: () -> Void + let logout: () -> Void + + var body: some View { + HStack(spacing: 12) { + statusStripe + + Button(action: useInCodex) { + HStack(spacing: 10) { + Image(systemName: account.codexActive ? "bolt.circle.fill" : "circle") + .foregroundStyle(account.codexActive ? .yellow : .secondary) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 4) { + Text(account.displayName) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + HStack(spacing: 7) { + Text(account.accountFingerprint) + Text(account.statusLabel) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Use in Codex") + + Button(action: pinForDecodex) { + Image(systemName: account.selected ? "pin.circle.fill" : "pin.circle") + } + .buttonStyle(.borderless) + .help(account.selected ? "Clear Decodex pin" : "Pin for Decodex runs") + + Button(role: .destructive, action: logout) { + Image(systemName: "rectangle.portrait.and.arrow.right") + } + .buttonStyle(.borderless) + .help("Log out") + } + .padding(.vertical, 10) + .padding(.horizontal, 10) + .background(rowBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var statusStripe: some View { + RoundedRectangle(cornerRadius: 2) + .fill(statusColor) + .frame(width: 4, height: 42) + } + + private var rowBackground: some ShapeStyle { + if account.codexActive { + return AnyShapeStyle(Color.yellow.opacity(0.16)) + } + if account.selected { + return AnyShapeStyle(Color(nsColor: .selectedContentBackgroundColor).opacity(0.28)) + } + + return AnyShapeStyle(.thinMaterial) + } + + private var statusColor: Color { + switch account.statusTone { + case .codexActive: return .yellow + case .ready: return .green + case .selected: return .accentColor + case .warning: return .yellow + case .danger: return .red + case .neutral: return .secondary + } + } +} + +struct NoticeView: View { + let text: String + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift new file mode 100644 index 0000000..bee75f9 --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -0,0 +1,124 @@ +import Foundation + +@MainActor +final class AccountStore: ObservableObject { + @Published private(set) var accountList: AccountListResponse? + @Published private(set) var isRefreshing = false + @Published private(set) var isLoggingIn = false + @Published var loginTranscript = "" + @Published var notice: String? + + private let bridge = DecodexAppBridge() + + var accounts: [CodexAccount] { + accountList?.accounts ?? [] + } + + var modeLabel: String { + guard let control = accountList?.control else { + return "Not loaded" + } + + let codexLabel = accountList?.codexAuth?.displayName ?? "no Codex auth" + if let selector = control.accountSelector, !selector.isEmpty { + return "Codex: \(codexLabel) / Decodex: \(selector)" + } + + let decodexLabel = control.mode == "balanced" ? "balanced" : control.mode + return "Codex: \(codexLabel) / Decodex: \(decodexLabel)" + } + + var menuSymbol: String { + if accounts.contains(where: \.codexActive) { + return "bolt.circle.fill" + } + if accounts.contains(where: \.selected) { + return "person.crop.circle.badge.checkmark" + } + if accounts.isEmpty { + return "person.crop.circle.badge.plus" + } + + return "person.2.circle" + } + + func refresh() async { + isRefreshing = true + defer { + isRefreshing = false + } + + do { + accountList = try await bridge.runJSON(.accountList, as: AccountListResponse.self) + notice = nil + } catch { + notice = error.localizedDescription + } + } + + func useInCodex(_ account: CodexAccount) async { + do { + _ = try await bridge.runJSON( + .accountUse(selector: account.selector), + as: CodexAuthUseResponse.self + ) + await refresh() + } catch { + notice = error.localizedDescription + } + } + + func select(_ account: CodexAccount) async { + do { + if account.selected { + accountList = try await bridge.runJSON(.accountClear, as: AccountListResponse.self) + } else { + accountList = try await bridge.runJSON( + .accountSelect(selector: account.selector), + as: AccountListResponse.self + ) + } + notice = nil + } catch { + notice = error.localizedDescription + } + } + + func clearSelection() async { + do { + accountList = try await bridge.runJSON(.accountClear, as: AccountListResponse.self) + notice = nil + } catch { + notice = error.localizedDescription + } + } + + func logout(_ account: CodexAccount) async { + do { + accountList = try await bridge.runJSON( + .accountLogout(selector: account.selector), + as: AccountListResponse.self + ) + notice = nil + } catch { + notice = error.localizedDescription + } + } + + func login() async { + isLoggingIn = true + loginTranscript = "" + notice = nil + + do { + accountList = try await bridge.runStreaming(.accountLogin(), as: AccountListResponse.self) { [weak self] chunk in + self?.loginTranscript += chunk + } + notice = nil + } catch { + notice = error.localizedDescription + } + + isLoggingIn = false + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift new file mode 100644 index 0000000..0a6c7dd --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + } +} + +private enum AppAssets { + static let statusBarIcon: NSImage = { + let image = NSImage(named: "StatusBarIcon") + ?? Bundle.main.url(forResource: "StatusBarIcon", withExtension: "png") + .flatMap(NSImage.init(contentsOf:)) + ?? NSImage(systemSymbolName: "person.2.circle", accessibilityDescription: "Decodex") + ?? NSImage() + image.isTemplate = true + image.size = NSSize(width: 22, height: 22) + return image + }() +} + +@main +struct DecodexApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var store = AccountStore() + + var body: some Scene { + MenuBarExtra { + AccountPanelView(store: store) + .task { + await store.refresh() + } + } label: { + Label { + Text("Decodex") + } icon: { + Image(nsImage: AppAssets.statusBarIcon) + } + } + .menuBarExtraStyle(.window) + + Settings { + SettingsView(store: store) + } + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift new file mode 100644 index 0000000..b22be9b --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift @@ -0,0 +1,257 @@ +import Foundation + +enum DecodexAppBridgeError: LocalizedError { + case helperMissing(String) + case launchFailed(String) + case commandFailed(Int32, String) + case invalidResponse(String) + + var errorDescription: String? { + switch self { + case .helperMissing(let message): + return message + case .launchFailed(let message): + return message + case .commandFailed(let code, let message): + return "Decodex App helper exited with status \(code): \(message)" + case .invalidResponse(let message): + return "Invalid Decodex App helper response: \(message)" + } + } +} + +struct AppBridgeRequest: Encodable, Sendable { + let operation: String + let selector: String? + let authJsonPath: String? + let codexBin: String? + let keepTempHome: Bool? + + enum CodingKeys: String, CodingKey { + case operation + case selector + case authJsonPath = "auth_json_path" + case codexBin = "codex_bin" + case keepTempHome = "keep_temp_home" + } + + static let accountList = AppBridgeRequest(operation: "account_list") + static let accountClear = AppBridgeRequest(operation: "account_clear") + + static func accountUse(selector: String) -> AppBridgeRequest { + AppBridgeRequest(operation: "account_use", selector: selector) + } + + static func accountSelect(selector: String) -> AppBridgeRequest { + AppBridgeRequest(operation: "account_select", selector: selector) + } + + static func accountLogout(selector: String) -> AppBridgeRequest { + AppBridgeRequest(operation: "account_logout", selector: selector) + } + + static func accountLogin() -> AppBridgeRequest { + AppBridgeRequest(operation: "account_login") + } + + private init( + operation: String, + selector: String? = nil, + authJsonPath: String? = nil, + codexBin: String? = nil, + keepTempHome: Bool? = nil + ) { + self.operation = operation + self.selector = selector + self.authJsonPath = authJsonPath + self.codexBin = codexBin + self.keepTempHome = keepTempHome + } +} + +private struct AppBridgeEvent: Decodable { + let kind: String + let text: String? + let payload: Response? + let message: String? +} + +private final class AppBridgeEventParser: @unchecked Sendable { + private let decoder = JSONDecoder() + private let lock = NSLock() + private let onOutput: (@MainActor @Sendable (String) -> Void)? + private var buffer = "" + private var response: Response? + private var bridgeError: String? + + init(onOutput: (@MainActor @Sendable (String) -> Void)? = nil) { + self.onOutput = onOutput + } + + func append(_ data: Data) throws { + guard !data.isEmpty else { + return + } + + lock.lock() + buffer += String(decoding: data, as: UTF8.self) + let lines = completeLines() + lock.unlock() + + for line in lines { + try handle(line) + } + } + + func finish() throws -> Response { + lock.lock() + let remainder = buffer + buffer = "" + lock.unlock() + + if !remainder.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try handle(remainder) + } + + if let bridgeError { + throw DecodexAppBridgeError.commandFailed(1, bridgeError) + } + guard let response else { + throw DecodexAppBridgeError.invalidResponse("missing result event") + } + + return response + } + + private func completeLines() -> [String] { + var lines: [String] = [] + + while let newlineIndex = buffer.firstIndex(of: "\n") { + lines.append(String(buffer[...self, from: data) + + switch event.kind { + case "output": + if let text = event.text { + Task { @MainActor in + onOutput?(text) + } + } + case "result": + guard let payload = event.payload else { + throw DecodexAppBridgeError.invalidResponse("result event omitted payload") + } + response = payload + case "error": + bridgeError = event.message ?? "unknown helper error" + default: + throw DecodexAppBridgeError.invalidResponse("unknown event kind \(event.kind)") + } + } +} + +struct DecodexAppBridge: Sendable { + func runJSON(_ request: AppBridgeRequest, as type: T.Type) async throws -> T { + try await runStreaming(request, as: type, onOutput: nil) + } + + func runStreaming( + _ request: AppBridgeRequest, + as type: T.Type, + onOutput: (@MainActor @Sendable (String) -> Void)? + ) async throws -> T { + let helperURL = try helperExecutableURL() + let requestData = try JSONEncoder().encode(request) + + return try await Task.detached(priority: .userInitiated) { + let process = Process() + let inputPipe = Pipe() + let outputPipe = Pipe() + let errorPipe = Pipe() + let parser = AppBridgeEventParser(onOutput: onOutput) + + process.executableURL = helperURL + process.standardInput = inputPipe + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + } catch { + throw DecodexAppBridgeError.launchFailed(error.localizedDescription) + } + + inputPipe.fileHandleForWriting.write(requestData) + inputPipe.fileHandleForWriting.write(Data([0x0a])) + try inputPipe.fileHandleForWriting.close() + + while true { + let data = outputPipe.fileHandleForReading.availableData + if data.isEmpty { + break + } + + try parser.append(data) + } + + process.waitUntilExit() + + let stderr = String( + decoding: errorPipe.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ) + + if process.terminationStatus != 0 { + do { + return try parser.finish() + } catch DecodexAppBridgeError.commandFailed(let code, let message) { + throw DecodexAppBridgeError.commandFailed(code, message) + } catch { + throw DecodexAppBridgeError.commandFailed( + process.terminationStatus, + stderr.isEmpty ? error.localizedDescription : stderr + ) + } + } + + return try parser.finish() + } + .value + } + + private func helperExecutableURL() throws -> URL { + if let override = ProcessInfo.processInfo.environment["DECODEX_APP_HELPER"], !override.isEmpty { + let overrideURL = URL(fileURLWithPath: override) + if FileManager.default.isExecutableFile(atPath: overrideURL.path) { + return overrideURL + } + } + + let bundledURL = Bundle.main.bundleURL + .appendingPathComponent("Contents") + .appendingPathComponent("Helpers") + .appendingPathComponent("decodex-app-helper") + if FileManager.default.isExecutableFile(atPath: bundledURL.path) { + return bundledURL + } + + throw DecodexAppBridgeError.helperMissing( + "Bundled Decodex App helper is missing. Rebuild the app bundle with apps/decodex-app/script/build_and_run.sh." + ) + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift new file mode 100644 index 0000000..146ac32 --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct LoginSheetView: View { + @ObservedObject var store: AccountStore + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + Image(systemName: "person.badge.key") + .font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text("Add Codex Login") + .font(.headline) + Text("A temporary Codex home is used, then the resulting auth file is imported.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + ScrollView { + Text(store.loginTranscript.isEmpty ? "Ready to start device login." : store.loginTranscript) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(10) + } + .frame(height: 220) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + HStack { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button { + Task { + await store.login() + if store.notice == nil { + dismiss() + } + } + } label: { + Label(store.isLoggingIn ? "Logging In" : "Start Login", systemImage: "arrow.right.circle") + } + .keyboardShortcut(.defaultAction) + .disabled(store.isLoggingIn) + } + } + .frame(width: 500) + .padding(18) + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift new file mode 100644 index 0000000..e20eb0d --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -0,0 +1,135 @@ +import Foundation + +struct AccountListResponse: Decodable { + let accountsPath: String + let globalConfigPath: String + let codexAuthPath: String + let codexAuth: CodexAuthIdentity? + let control: AccountControl + let accounts: [CodexAccount] + + enum CodingKeys: String, CodingKey { + case accountsPath = "accounts_path" + case globalConfigPath = "global_config_path" + case codexAuthPath = "codex_auth_path" + case codexAuth = "codex_auth" + case control + case accounts + } +} + +struct CodexAuthIdentity: Decodable { + let accountFingerprint: String + let email: String? + let selector: String + + var displayName: String { + email ?? accountFingerprint + } + + enum CodingKeys: String, CodingKey { + case accountFingerprint = "account_fingerprint" + case email + case selector + } +} + +struct CodexAuthUseResponse: Decodable { + let codexAuthPath: String + let account: CodexAuthIdentity + + enum CodingKeys: String, CodingKey { + case codexAuthPath = "codex_auth_path" + case account + } +} + +struct AccountControl: Decodable { + let mode: String + let accountSelector: String? + + enum CodingKeys: String, CodingKey { + case mode + case accountSelector = "account_selector" + } +} + +struct CodexAccount: Decodable, Identifiable, Equatable { + let accountFingerprint: String + let email: String? + let selector: String + let status: String + let selected: Bool + let codexActive: Bool + let disabled: Bool + let refreshTokenPresent: Bool + let accessTokenExpiresAtUnixEpoch: Int? + let lastSelectedAtUnixEpoch: Int? + let cooldownUntilUnixEpoch: Int? + let note: String? + + var id: String { + email ?? accountFingerprint + } + + var displayName: String { + email ?? accountFingerprint + } + + var statusLabel: String { + if codexActive { + return "Codex active" + } + if selected { + return "Decodex pinned" + } + + switch status { + case "available": return "Ready" + case "expired": return "Refresh needed" + case "disabled": return "Disabled" + case "cooldown": return "Cooling" + case "unusable": return "Needs login" + default: return status.replacingOccurrences(of: "_", with: " ").capitalized + } + } + + var statusTone: AccountTone { + if codexActive { + return .codexActive + } + if selected { + return .selected + } + switch status { + case "available": return .ready + case "cooldown": return .warning + case "expired", "unusable", "disabled": return .danger + default: return .neutral + } + } + + enum CodingKeys: String, CodingKey { + case accountFingerprint = "account_fingerprint" + case email + case selector + case status + case selected + case codexActive = "codex_active" + case disabled + case refreshTokenPresent = "refresh_token_present" + case accessTokenExpiresAtUnixEpoch = "access_token_expires_at_unix_epoch" + case lastSelectedAtUnixEpoch = "last_selected_at_unix_epoch" + case cooldownUntilUnixEpoch = "cooldown_until_unix_epoch" + case note + } +} + +enum AccountTone { + case codexActive + case ready + case selected + case warning + case danger + case neutral +} diff --git a/apps/decodex-app/Sources/DecodexApp/SettingsView.swift b/apps/decodex-app/Sources/DecodexApp/SettingsView.swift new file mode 100644 index 0000000..2ee22f1 --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/SettingsView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var store: AccountStore + + var body: some View { + Form { + Section("Paths") { + pathRow("Accounts", store.accountList?.accountsPath) + pathRow("Config", store.accountList?.globalConfigPath) + pathRow("Codex Auth", store.accountList?.codexAuthPath) + } + + Section("Helper") { + Text(ProcessInfo.processInfo.environment["DECODEX_APP_HELPER"] ?? "Bundled decodex-app-helper") + .textSelection(.enabled) + } + } + .formStyle(.grouped) + .frame(width: 520, height: 250) + .padding() + .task { + await store.refresh() + } + } + + private func pathRow(_ title: String, _ path: String?) -> some View { + HStack { + Text(title) + Spacer() + Text(path ?? "-") + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } +} diff --git a/apps/decodex-app/script/build_and_run.sh b/apps/decodex-app/script/build_and_run.sh new file mode 100755 index 0000000..e18e496 --- /dev/null +++ b/apps/decodex-app/script/build_and_run.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-run}" +PRODUCT_NAME="Decodex App" +EXECUTABLE_NAME="DecodexApp" +HELPER_NAME="decodex-app-helper" +BUNDLE_ID="space.decodex.app" +MIN_SYSTEM_VERSION="14.0" +DEFAULT_SIGN_IDENTITY="x@acg.box" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKTREE_ROOT="$(git -C "$ROOT_DIR" rev-parse --show-toplevel)" +COMMON_ROOT="$(cd "$(git -C "$ROOT_DIR" rev-parse --git-common-dir)/.." && pwd)" +STAGE_DIR="${DECODEX_APP_STAGE_DIR:-$COMMON_ROOT/target/decodex-app}" +APP_BUNDLE="$STAGE_DIR/$PRODUCT_NAME.app" +APP_CONTENTS="$APP_BUNDLE/Contents" +APP_MACOS="$APP_CONTENTS/MacOS" +APP_HELPERS="$APP_CONTENTS/Helpers" +APP_RESOURCES="$APP_CONTENTS/Resources" +APP_BINARY="$APP_MACOS/$EXECUTABLE_NAME" +APP_HELPER_BINARY="$APP_HELPERS/$HELPER_NAME" +INFO_PLIST="$APP_CONTENTS/Info.plist" +APP_ICON_SOURCE="$WORKTREE_ROOT/assets/app-icon/generated/app-icon.icns" +APP_ICON_NAME="AppIcon.icns" +STATUS_ICON_SOURCE="$WORKTREE_ROOT/assets/tray-icon/generated/tray-icon-template.png" +STATUS_ICON_NAME="StatusBarIcon.png" +SWIFT_BUILD_FLAGS=() +RUST_BUILD_FLAGS=() +RUST_TARGET_DIR="" +BUILD_ROOT="" +BUILD_BINARY="" +HELPER_BINARY="" +RESOLVED_SIGN_IDENTITY="" + +SWIFT_CONFIGURATION="${DECODEX_APP_SWIFT_CONFIGURATION:-debug}" +if [[ "$SWIFT_CONFIGURATION" == "release" ]]; then + SWIFT_BUILD_FLAGS=(-c release) + RUST_BUILD_FLAGS=(--release) +elif [[ "$SWIFT_CONFIGURATION" != "debug" ]]; then + echo "error: DECODEX_APP_SWIFT_CONFIGURATION must be debug or release." >&2 + exit 2 +fi + +APP_VERSION="${DECODEX_APP_VERSION:-}" +if [[ -z "$APP_VERSION" ]]; then + APP_VERSION="$( + sed -n '/^\[workspace.package\]/,/^\[/s/^version *= *"\(.*\)"/\1/p' \ + "$WORKTREE_ROOT/Cargo.toml" | head -n 1 + )" +fi +APP_VERSION="${APP_VERSION:-0.1.0}" + +terminate_running_app() { + pkill -x "$EXECUTABLE_NAME" >/dev/null 2>&1 || true +} + +write_info_plist() { + cat >"$INFO_PLIST" < + + + + CFBundleExecutable + $EXECUTABLE_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleName + $PRODUCT_NAME + CFBundleDisplayName + $PRODUCT_NAME + CFBundleIconFile + ${APP_ICON_NAME%.icns} + CFBundlePackageType + APPL + CFBundleShortVersionString + $APP_VERSION + CFBundleVersion + $APP_VERSION + LSMinimumSystemVersion + $MIN_SYSTEM_VERSION + LSUIElement + + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST +} + +resolve_signing_identity() { + local requested_identity identity_list identity + + requested_identity="${DECODEX_APP_SIGN_IDENTITY:-$DEFAULT_SIGN_IDENTITY}" + identity_list="$(security find-identity -v -p codesigning 2>/dev/null || true)" + if [[ -n "$requested_identity" ]]; then + while IFS= read -r line; do + identity="${line#*\"}" + identity="${identity%%\"*}" + if [[ -n "$identity" && "$identity" == *"$requested_identity"* ]]; then + RESOLVED_SIGN_IDENTITY="$identity" + return 0 + fi + done <<<"$identity_list" + fi + + while IFS= read -r line; do + identity="${line#*\"}" + identity="${identity%%\"*}" + if [[ -n "$identity" && "$identity" == Apple\ Development:* ]]; then + RESOLVED_SIGN_IDENTITY="$identity" + return 0 + fi + done <<<"$identity_list" + + return 1 +} + +sign_staged_app_bundle() { + local requested_identity entitlements_file + + requested_identity="${DECODEX_APP_SIGN_IDENTITY:-$DEFAULT_SIGN_IDENTITY}" + if ! resolve_signing_identity; then + echo "error: no valid macOS codesigning identity matching \"$requested_identity\" was found." >&2 + echo "error: import the real signing certificate or set DECODEX_APP_SIGN_IDENTITY to a valid identity." >&2 + echo "error: Decodex App staging requires a stable codesigning identity." >&2 + exit 1 + fi + + codesign \ + --force \ + --options runtime \ + --sign "$RESOLVED_SIGN_IDENTITY" \ + "$APP_HELPER_BINARY" + + entitlements_file="$BUILD_ROOT/$EXECUTABLE_NAME-entitlement.plist" + if [[ -f "$entitlements_file" ]]; then + codesign \ + --force \ + --deep \ + --options runtime \ + --sign "$RESOLVED_SIGN_IDENTITY" \ + --entitlements "$entitlements_file" \ + "$APP_BUNDLE" + else + codesign \ + --force \ + --deep \ + --options runtime \ + --sign "$RESOLVED_SIGN_IDENTITY" \ + "$APP_BUNDLE" + fi +} + +stage_app_bundle() { + BUILD_ROOT="$(swift build --package-path "$ROOT_DIR" "${SWIFT_BUILD_FLAGS[@]}" --show-bin-path)" + BUILD_BINARY="$BUILD_ROOT/$EXECUTABLE_NAME" + RUST_TARGET_DIR="$COMMON_ROOT/target" + + swift build --package-path "$ROOT_DIR" "${SWIFT_BUILD_FLAGS[@]}" --product "$EXECUTABLE_NAME" + CARGO_TARGET_DIR="$RUST_TARGET_DIR" cargo build -p decodex --bin "$HELPER_NAME" "${RUST_BUILD_FLAGS[@]}" + + if [[ "$SWIFT_CONFIGURATION" == "release" ]]; then + HELPER_BINARY="$RUST_TARGET_DIR/release/$HELPER_NAME" + else + HELPER_BINARY="$RUST_TARGET_DIR/debug/$HELPER_NAME" + fi + + rm -rf "$APP_BUNDLE" + mkdir -p "$APP_MACOS" "$APP_HELPERS" "$APP_RESOURCES" + cp "$BUILD_BINARY" "$APP_BINARY" + cp "$HELPER_BINARY" "$APP_HELPER_BINARY" + chmod +x "$APP_BINARY" + chmod +x "$APP_HELPER_BINARY" + if [[ -f "$APP_ICON_SOURCE" ]]; then + cp "$APP_ICON_SOURCE" "$APP_RESOURCES/$APP_ICON_NAME" + fi + if [[ -f "$STATUS_ICON_SOURCE" ]]; then + cp "$STATUS_ICON_SOURCE" "$APP_RESOURCES/$STATUS_ICON_NAME" + fi + write_info_plist + sign_staged_app_bundle + codesign --verify --deep --strict "$APP_BUNDLE" +} + +if [[ "$MODE" != "stage" && "$MODE" != "--stage" ]]; then + terminate_running_app +fi + +stage_app_bundle + +open_app() { + /usr/bin/open "$APP_BUNDLE" +} + +case "$MODE" in + stage|--stage) + ;; + run) + open_app + ;; + --debug|debug) + lldb -- "$APP_BINARY" + ;; + --logs|logs) + open_app + /usr/bin/log stream --info --style compact --predicate "process == \"$EXECUTABLE_NAME\"" + ;; + --verify|verify) + open_app + sleep 1 + pgrep -x "$EXECUTABLE_NAME" >/dev/null + codesign --verify --deep --strict "$APP_BUNDLE" + ;; + *) + echo "usage: $0 [run|stage|--debug|--logs|--verify]" >&2 + exit 2 + ;; +esac diff --git a/apps/decodex/src/accounts.rs b/apps/decodex/src/accounts.rs new file mode 100644 index 0000000..4bb6136 --- /dev/null +++ b/apps/decodex/src/accounts.rs @@ -0,0 +1,1289 @@ +#[cfg(unix)] use std::os::unix::fs::PermissionsExt as _; +use std::{ + env, fs, + io::{self, ErrorKind, Read, Write as _}, + path::{Path, PathBuf}, + process::{self, Child, Command, ExitStatus, Stdio}, + sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}, + thread::{self, JoinHandle}, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use crate::{ + prelude::{Result, eyre}, + runtime, +}; + +pub(crate) struct AccountLoginRequest { + pub(crate) codex_bin: String, + pub(crate) keep_temp_home: bool, +} + +pub(crate) struct AccountImportRequest { + pub(crate) auth_json_path: PathBuf, + pub(crate) json: bool, +} + +pub(crate) struct AccountUseRequest { + pub(crate) selector: String, + pub(crate) auth_json_path: Option, + pub(crate) json: bool, +} + +pub(crate) struct AccountStore { + accounts_path: PathBuf, + global_config_path: PathBuf, + codex_auth_path: PathBuf, +} +impl AccountStore { + pub(crate) fn global() -> Result { + Ok(Self { + accounts_path: runtime::accounts_path()?, + global_config_path: runtime::global_config_path()?, + codex_auth_path: default_codex_auth_json_path()?, + }) + } + + #[cfg(test)] + fn new(accounts_path: PathBuf, global_config_path: PathBuf) -> Self { + let codex_auth_path = accounts_path + .parent() + .map(|parent| parent.join("auth.json")) + .unwrap_or_else(|| PathBuf::from("auth.json")); + + Self { accounts_path, global_config_path, codex_auth_path } + } + + #[cfg(test)] + fn new_with_codex_auth_path( + accounts_path: PathBuf, + global_config_path: PathBuf, + codex_auth_path: PathBuf, + ) -> Self { + Self { accounts_path, global_config_path, codex_auth_path } + } + + fn list(&self) -> Result { + let records = self.load_records()?; + + self.response_from_records(&records) + } + + fn select(&self, selector: &str) -> Result { + let selector = selector.trim(); + + if selector.is_empty() { + eyre::bail!("Codex account selector cannot be empty."); + } + + let records = self.load_records()?; + + if !records.iter().any(|record| record.matches_account_selector(selector)) { + eyre::bail!("No Decodex account matches selector `{selector}`."); + } + + self.write_fixed_account_selector(Some(selector))?; + + self.response_from_records(&records) + } + + fn clear_selection(&self) -> Result { + let records = self.load_records()?; + + self.write_fixed_account_selector(None)?; + + self.response_from_records(&records) + } + + fn logout(&self, selector: &str) -> Result { + let selector = selector.trim(); + + if selector.is_empty() { + eyre::bail!("Codex account selector cannot be empty."); + } + + let mut records = self.load_records()?; + let selector_matched_fixed = + self.fixed_account_selector()?.as_deref().is_some_and(|fixed| { + fixed == selector + || records.iter().any(|record| { + record.matches_account_selector(selector) + && record.matches_account_selector(fixed) + }) + }); + let original_len = records.len(); + + records.retain(|record| !record.matches_account_selector(selector)); + + if records.len() == original_len { + eyre::bail!("No Decodex account matches selector `{selector}`."); + } + + self.save_records(&records)?; + + if selector_matched_fixed { + self.write_fixed_account_selector(None)?; + } + + self.response_from_records(&records) + } + + fn import_auth_json(&self, auth_json_path: &Path) -> Result { + let input = fs::read_to_string(auth_json_path).map_err(|error| { + eyre::eyre!("Failed to read Codex auth JSON `{}`: {error}", auth_json_path.display()) + })?; + let auth = serde_json::from_str::(&input).map_err(|error| { + eyre::eyre!("Codex auth JSON `{}` is invalid: {error}", auth_json_path.display()) + })?; + let mut record = AccountPoolRecord::from_auth(auth)?; + let mut records = self.load_records()?; + + if record.last_refresh.is_none() { + record.last_refresh = Some(now_rfc3339()?); + } + + let replace_index = records.iter().position(|candidate| { + record.account_id().is_some() && candidate.account_id() == record.account_id() + || record.email().is_some() && candidate.email() == record.email() + }); + + if let Some(index) = replace_index { + records[index] = record; + } else { + records.push(record); + } + + self.save_records(&records)?; + + self.response_from_records(&records) + } + + fn use_for_codex( + &self, + selector: &str, + auth_json_path: Option<&Path>, + ) -> Result { + let selector = selector.trim(); + + if selector.is_empty() { + eyre::bail!("Codex account selector cannot be empty."); + } + + let records = self.load_records()?; + let record = records + .iter() + .find(|record| record.matches_account_selector(selector)) + .ok_or_else(|| eyre::eyre!("No Decodex account matches selector `{selector}`."))?; + + if record.disabled { + eyre::bail!("Decodex account `{selector}` is disabled and cannot be used by Codex."); + } + + record.validate_importable()?; + + let target_path = auth_json_path.unwrap_or(&self.codex_auth_path); + + write_auth_json_atomically(target_path, &record.auth_dot_json()?)?; + + Ok(AccountUseResponse { + codex_auth_path: target_path.display().to_string(), + account: record.identity_summary(), + }) + } + + fn load_records(&self) -> Result> { + let input = match fs::read_to_string(&self.accounts_path) { + Ok(input) => input, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + eyre::bail!( + "Failed to read Decodex accounts `{}`: {error}", + self.accounts_path.display() + ); + }, + }; + + parse_account_records(&input, &self.accounts_path) + } + + fn save_records(&self, records: &[AccountPoolRecord]) -> Result<()> { + let parent = self.accounts_path.parent().ok_or_else(|| { + eyre::eyre!( + "Decodex accounts path `{}` must have a parent directory.", + self.accounts_path.display() + ) + })?; + let file_name = + self.accounts_path.file_name().and_then(|name| name.to_str()).ok_or_else(|| { + eyre::eyre!("Decodex accounts path must end in a valid file name.") + })?; + let temp_path = parent.join(format!(".{file_name}.tmp-{}", process::id())); + let mut body = String::new(); + + for record in records { + body.push_str(&serde_json::to_string(record)?); + body.push('\n'); + } + + fs::create_dir_all(parent)?; + fs::write(&temp_path, body)?; + + secure_account_file(&temp_path)?; + + fs::rename(temp_path, &self.accounts_path)?; + + secure_account_file(&self.accounts_path)?; + + Ok(()) + } + + fn response_from_records(&self, records: &[AccountPoolRecord]) -> Result { + let selector = self.fixed_account_selector()?; + let codex_auth = self.codex_auth_identity().unwrap_or_default(); + let control = AccountControlSummary { + mode: if selector.is_some() { String::from("fixed") } else { String::from("balanced") }, + account_selector: selector.clone(), + }; + let accounts = records + .iter() + .map(|record| record.summary(selector.as_deref(), codex_auth.as_ref())) + .collect::>(); + + Ok(AccountListResponse { + accounts_path: self.accounts_path.display().to_string(), + global_config_path: self.global_config_path.display().to_string(), + codex_auth_path: self.codex_auth_path.display().to_string(), + codex_auth: codex_auth.as_ref().map(AccountIdentity::summary), + control, + accounts, + }) + } + + fn codex_auth_identity(&self) -> Result> { + let input = match fs::read_to_string(&self.codex_auth_path) { + Ok(input) => input, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + eyre::bail!( + "Failed to read Codex auth JSON `{}`: {error}", + self.codex_auth_path.display() + ); + }, + }; + let auth = serde_json::from_str::(&input).map_err(|error| { + eyre::eyre!("Codex auth JSON `{}` is invalid: {error}", self.codex_auth_path.display()) + })?; + let record = AccountPoolRecord::from_auth(auth)?; + + Ok(Some(record.identity())) + } + + fn fixed_account_selector(&self) -> Result> { + let input = match fs::read_to_string(&self.global_config_path) { + Ok(input) => input, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + eyre::bail!( + "Failed to read Decodex global config `{}`: {error}", + self.global_config_path.display() + ); + }, + }; + let document = toml::from_str::(&input)?; + let selector = document + .get("codex") + .and_then(toml::Value::as_table) + .and_then(|codex| codex.get("accounts")) + .and_then(toml::Value::as_table) + .and_then(|accounts| accounts.get("fixed_account")) + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + + Ok(selector) + } + + fn write_fixed_account_selector(&self, selector: Option<&str>) -> Result<()> { + let input = match fs::read_to_string(&self.global_config_path) { + Ok(input) => input, + Err(error) if error.kind() == ErrorKind::NotFound => String::new(), + Err(error) => { + eyre::bail!( + "Failed to read Decodex global config `{}`: {error}", + self.global_config_path.display() + ); + }, + }; + let mut document = if input.trim().is_empty() { + toml::Table::new() + } else { + toml::from_str::(&input)? + }; + + match selector.map(str::trim).filter(|value| !value.is_empty()) { + Some(selector) => { + let accounts = + ensure_toml_table(ensure_toml_table(&mut document, "codex")?, "accounts")?; + + accounts.insert(String::from("fixed_account"), selector.to_owned().into()); + }, + None => { + if let Some(codex) = document.get_mut("codex").and_then(toml::Value::as_table_mut) + && let Some(accounts) = + codex.get_mut("accounts").and_then(toml::Value::as_table_mut) + { + accounts.remove("fixed_account"); + } + }, + } + + let parent = self.global_config_path.parent().ok_or_else(|| { + eyre::eyre!( + "Decodex global config `{}` must have a parent directory.", + self.global_config_path.display() + ) + })?; + let file_name = + self.global_config_path.file_name().and_then(|name| name.to_str()).ok_or_else( + || eyre::eyre!("Decodex global config path must end in a valid file name."), + )?; + let temp_path = parent.join(format!(".{file_name}.tmp-{}", process::id())); + let output = toml::to_string_pretty(&document)?; + + fs::create_dir_all(parent)?; + fs::write(&temp_path, output)?; + + secure_account_file(&temp_path)?; + + fs::rename(temp_path, &self.global_config_path)?; + + secure_account_file(&self.global_config_path)?; + + Ok(()) + } +} + +#[derive(Serialize)] +pub(crate) struct AccountListResponse { + pub(crate) accounts_path: String, + pub(crate) global_config_path: String, + pub(crate) codex_auth_path: String, + pub(crate) codex_auth: Option, + pub(crate) control: AccountControlSummary, + pub(crate) accounts: Vec, +} + +#[derive(Serialize)] +pub(crate) struct AccountUseResponse { + pub(crate) codex_auth_path: String, + pub(crate) account: AccountIdentitySummary, +} + +#[derive(Clone, Serialize)] +pub(crate) struct AccountIdentitySummary { + pub(crate) account_fingerprint: String, + pub(crate) email: Option, + pub(crate) selector: String, +} + +#[derive(Serialize)] +pub(crate) struct AccountControlSummary { + pub(crate) mode: String, + pub(crate) account_selector: Option, +} + +#[derive(Serialize)] +pub(crate) struct AccountSummary { + pub(crate) account_fingerprint: String, + pub(crate) email: Option, + pub(crate) selector: String, + pub(crate) status: String, + pub(crate) selected: bool, + pub(crate) codex_active: bool, + pub(crate) disabled: bool, + pub(crate) refresh_token_present: bool, + pub(crate) access_token_expires_at_unix_epoch: Option, + pub(crate) last_selected_at_unix_epoch: Option, + pub(crate) cooldown_until_unix_epoch: Option, + pub(crate) note: Option, +} + +#[derive(Clone)] +struct AccountIdentity { + account_id: Option, + email: Option, +} +impl AccountIdentity { + fn summary(&self) -> AccountIdentitySummary { + let account_fingerprint = self + .account_id + .as_deref() + .map(redact_account_id) + .or_else(|| self.email.clone()) + .unwrap_or_else(|| String::from("unknown")); + let selector = self.email.clone().unwrap_or_else(|| account_fingerprint.clone()); + + AccountIdentitySummary { account_fingerprint, email: self.email.clone(), selector } + } +} + +#[derive(Clone, Deserialize, Serialize)] +struct AuthDotJson { + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auth_mode: Option, + #[serde(rename = "OPENAI_API_KEY", skip_serializing_if = "Option::is_none")] + openai_api_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_refresh: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +struct AccountPoolRecord { + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, + #[serde(default, skip_serializing_if = "is_false")] + disabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + cooldown_until_unix_epoch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cooldown_until: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_selected_at_unix_epoch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auth_mode: Option, + #[serde(rename = "OPENAI_API_KEY", skip_serializing_if = "Option::is_none")] + openai_api_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_refresh: Option, +} +impl AccountPoolRecord { + fn from_auth(auth: AuthDotJson) -> Result { + let record = Self { + email: first_nonblank_string( + auth.email, + auth.tokens.as_ref().and_then(|tokens| { + nonblank_string(tokens.email.as_deref()) + .or_else(|| jwt_email_claim(tokens.id_token.as_deref())) + }), + ), + disabled: false, + cooldown_until_unix_epoch: None, + cooldown_until: None, + last_selected_at_unix_epoch: None, + auth_mode: auth.auth_mode, + openai_api_key: auth.openai_api_key, + tokens: auth.tokens, + last_refresh: auth.last_refresh, + }; + + record.validate_importable()?; + + Ok(record) + } + + fn validate_importable(&self) -> Result<()> { + if self + .tokens + .as_ref() + .and_then(|tokens| nonblank_string(Some(&tokens.access_token))) + .is_none() + { + eyre::bail!("Codex auth JSON is missing `tokens.access_token`."); + } + if self + .tokens + .as_ref() + .and_then(|tokens| nonblank_string(Some(&tokens.refresh_token))) + .is_none() + { + eyre::bail!("Codex auth JSON is missing `tokens.refresh_token`."); + } + if self.account_id().is_none() { + eyre::bail!("Codex auth JSON is missing `tokens.account_id`."); + } + + Ok(()) + } + + fn matches_account_selector(&self, selector: &str) -> bool { + let selector = selector.trim(); + + self.email().as_deref() == Some(selector) + || self.account_id() == Some(selector) + || self.account_id().map(redact_account_id).as_deref() == Some(selector) + } + + fn matches_account_identity(&self, identity: &AccountIdentity) -> bool { + identity + .account_id + .as_deref() + .is_some_and(|account_id| self.account_id() == Some(account_id)) + || identity.email.as_deref().is_some_and(|email| self.email().as_deref() == Some(email)) + } + + fn account_id(&self) -> Option<&str> { + self.tokens + .as_ref() + .and_then(|tokens| tokens.account_id.as_deref()) + .filter(|account_id| !account_id.trim().is_empty()) + } + + fn email(&self) -> Option { + nonblank_string(self.email.as_deref()) + .or_else(|| { + self.tokens.as_ref().and_then(|tokens| nonblank_string(tokens.email.as_deref())) + }) + .or_else(|| { + self.tokens.as_ref().and_then(|tokens| jwt_email_claim(tokens.id_token.as_deref())) + }) + } + + fn identity(&self) -> AccountIdentity { + AccountIdentity { account_id: self.account_id().map(str::to_owned), email: self.email() } + } + + fn identity_summary(&self) -> AccountIdentitySummary { + self.identity().summary() + } + + fn auth_dot_json(&self) -> Result { + self.validate_importable()?; + + Ok(AuthDotJson { + email: self.email(), + auth_mode: self.auth_mode.clone(), + openai_api_key: self.openai_api_key.clone(), + tokens: self.tokens.clone(), + last_refresh: self.last_refresh.clone(), + }) + } + + fn summary( + &self, + fixed_selector: Option<&str>, + codex_auth: Option<&AccountIdentity>, + ) -> AccountSummary { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let account_fingerprint = self + .account_id() + .map(redact_account_id) + .or_else(|| self.email()) + .unwrap_or_else(|| String::from("unknown")); + let selector = self.email().unwrap_or_else(|| account_fingerprint.clone()); + let selected = fixed_selector.is_some_and(|fixed| self.matches_account_selector(fixed)); + let access_token_expires_at_unix_epoch = + self.tokens.as_ref().and_then(|tokens| jwt_expiration_unix_epoch(&tokens.access_token)); + let refresh_token_present = self + .tokens + .as_ref() + .and_then(|tokens| nonblank_string(Some(&tokens.refresh_token))) + .is_some(); + let status = if self.disabled { + "disabled" + } else if self.cooldown_until_unix_epoch.is_some_and(|cooldown_until| cooldown_until > now) + { + "cooldown" + } else if access_token_expires_at_unix_epoch.is_some_and(|expires_at| expires_at <= now) { + "expired" + } else if self.account_id().is_none() || !refresh_token_present { + "unusable" + } else { + "available" + }; + + AccountSummary { + account_fingerprint, + email: self.email(), + selector, + status: status.to_owned(), + selected, + codex_active: codex_auth + .is_some_and(|identity| self.matches_account_identity(identity)), + disabled: self.disabled, + refresh_token_present, + access_token_expires_at_unix_epoch, + last_selected_at_unix_epoch: self.last_selected_at_unix_epoch, + cooldown_until_unix_epoch: self.cooldown_until_unix_epoch, + note: Some(String::from("local account pool")), + } + } +} + +#[derive(Clone, Deserialize, Serialize)] +struct CodexTokenData { + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + id_token: Option, + access_token: String, + refresh_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + account_id: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(untagged)] +enum AccountPoolLine { + Wrapped { + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, + #[serde(default, skip_serializing_if = "is_false")] + disabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + cooldown_until_unix_epoch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cooldown_until: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_selected_at_unix_epoch: Option, + auth: AuthDotJson, + }, + Flat(AccountPoolRecord), +} +impl AccountPoolLine { + fn into_record(self) -> Result { + match self { + Self::Flat(record) => Ok(record), + Self::Wrapped { + email, + disabled, + cooldown_until_unix_epoch, + cooldown_until, + last_selected_at_unix_epoch, + auth, + } => { + let mut record = AccountPoolRecord::from_auth(auth)?; + + record.email = first_nonblank_string(email, record.email); + record.disabled = disabled; + record.cooldown_until_unix_epoch = cooldown_until_unix_epoch; + record.cooldown_until = cooldown_until; + record.last_selected_at_unix_epoch = last_selected_at_unix_epoch; + + Ok(record) + }, + } + } +} + +enum LoginPipeEvent { + Chunk(Vec), + ReaderFailed(String), +} + +pub(crate) fn run_account_list(json: bool) -> Result<()> { + print_list_response(&account_list()?, json) +} + +pub(crate) fn run_account_select(selector: &str, json: bool) -> Result<()> { + print_list_response(&account_select(selector)?, json) +} + +pub(crate) fn run_account_clear(json: bool) -> Result<()> { + print_list_response(&account_clear()?, json) +} + +pub(crate) fn run_account_logout(selector: &str, json: bool) -> Result<()> { + print_list_response(&account_logout(selector)?, json) +} + +pub(crate) fn run_account_import(request: &AccountImportRequest) -> Result<()> { + print_list_response(&account_import(&request.auth_json_path)?, request.json) +} + +pub(crate) fn run_account_use(request: &AccountUseRequest) -> Result<()> { + print_use_response(&account_use(request)?, request.json) +} + +pub(crate) fn run_account_login(request: &AccountLoginRequest) -> Result<()> { + let response = account_login(request, |chunk| { + print!("{chunk}"); + + io::stdout().flush()?; + + Ok(()) + })?; + + print_list_response(&response, false) +} + +pub(crate) fn account_list() -> Result { + AccountStore::global()?.list() +} + +pub(crate) fn account_select(selector: &str) -> Result { + AccountStore::global()?.select(selector) +} + +pub(crate) fn account_clear() -> Result { + AccountStore::global()?.clear_selection() +} + +pub(crate) fn account_logout(selector: &str) -> Result { + AccountStore::global()?.logout(selector) +} + +pub(crate) fn account_import(auth_json_path: &Path) -> Result { + AccountStore::global()?.import_auth_json(auth_json_path) +} + +pub(crate) fn account_use(request: &AccountUseRequest) -> Result { + AccountStore::global()?.use_for_codex(&request.selector, request.auth_json_path.as_deref()) +} + +pub(crate) fn account_login( + request: &AccountLoginRequest, + on_output: impl FnMut(&str) -> Result<()>, +) -> Result { + let temp_home = create_login_home()?; + let status = run_codex_device_login(&request.codex_bin, &temp_home, on_output)?; + + if !status.success() { + cleanup_login_home(&temp_home, request.keep_temp_home); + + eyre::bail!("Codex account login failed with status {status}."); + } + + let auth_json_path = temp_home.join("auth.json"); + let store = AccountStore::global()?; + let import_result = store.import_auth_json(&auth_json_path); + + cleanup_login_home(&temp_home, request.keep_temp_home); + + import_result +} + +fn run_codex_device_login( + codex_bin: &str, + temp_home: &Path, + on_output: impl FnMut(&str) -> Result<()>, +) -> Result { + let mut child = Command::new(codex_bin) + .arg("login") + .arg("--device-auth") + .env("CODEX_HOME", temp_home) + .env("CODEX_SQLITE_HOME", temp_home) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + eyre::eyre!("Failed to start `{codex_bin}` for Codex account login: {error}") + })?; + let stdout = + child.stdout.take().ok_or_else(|| eyre::eyre!("Failed to capture Codex login stdout."))?; + let stderr = + child.stderr.take().ok_or_else(|| eyre::eyre!("Failed to capture Codex login stderr."))?; + let (sender, receiver) = mpsc::channel(); + let stdout_reader = spawn_login_pipe_reader(stdout, sender.clone()); + let stderr_reader = spawn_login_pipe_reader(stderr, sender); + let status = wait_for_login_child(child, receiver, on_output)?; + + join_login_pipe_reader(stdout_reader)?; + join_login_pipe_reader(stderr_reader)?; + + Ok(status) +} + +fn spawn_login_pipe_reader( + mut reader: impl Read + Send + 'static, + sender: Sender, +) -> JoinHandle<()> { + thread::spawn(move || { + let mut buffer = [0_u8; 4_096]; + + loop { + match reader.read(&mut buffer) { + Ok(0) => return, + Ok(len) => + if sender.send(LoginPipeEvent::Chunk(buffer[..len].to_vec())).is_err() { + return; + }, + Err(error) => { + let _ = sender.send(LoginPipeEvent::ReaderFailed(error.to_string())); + + return; + }, + } + } + }) +} + +fn wait_for_login_child( + mut child: Child, + receiver: Receiver, + mut on_output: impl FnMut(&str) -> Result<()>, +) -> Result { + let mut reader_error = None; + + loop { + while let Ok(event) = receiver.try_recv() { + handle_login_pipe_event(event, &mut on_output, &mut reader_error)?; + } + + if let Some(status) = child.try_wait()? { + while let Ok(event) = receiver.try_recv() { + handle_login_pipe_event(event, &mut on_output, &mut reader_error)?; + } + + if let Some(error) = reader_error { + eyre::bail!("Failed while reading Codex login output: {error}"); + } + + return Ok(status); + } + + match receiver.recv_timeout(Duration::from_millis(50)) { + Ok(event) => handle_login_pipe_event(event, &mut on_output, &mut reader_error)?, + Err(RecvTimeoutError::Timeout) => {}, + Err(RecvTimeoutError::Disconnected) => { + let status = child.wait()?; + + if let Some(error) = reader_error { + eyre::bail!("Failed while reading Codex login output: {error}"); + } + + return Ok(status); + }, + } + } +} + +fn handle_login_pipe_event( + event: LoginPipeEvent, + on_output: &mut impl FnMut(&str) -> Result<()>, + reader_error: &mut Option, +) -> Result<()> { + match event { + LoginPipeEvent::Chunk(chunk) => on_output(&String::from_utf8_lossy(&chunk))?, + LoginPipeEvent::ReaderFailed(error) => *reader_error = Some(error), + } + + Ok(()) +} + +fn join_login_pipe_reader(handle: JoinHandle<()>) -> Result<()> { + handle.join().map_err(|_| eyre::eyre!("Codex login output reader panicked.")) +} + +fn parse_account_records(input: &str, path: &Path) -> Result> { + let mut records = Vec::new(); + + for (line_index, line) in input.lines().enumerate() { + let line_number = line_index + 1; + let trimmed = line.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let parsed = serde_json::from_str::(trimmed).map_err(|error| { + eyre::eyre!( + "Decodex accounts `{}` line {line_number} is not a valid auth JSONL entry: {error}", + path.display() + ) + })?; + + records.push(parsed.into_record()?); + } + + Ok(records) +} + +fn print_list_response(response: &AccountListResponse, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(response)?); + + return Ok(()); + } + + println!( + "Codex account pool: {} ({})", + response.control.mode, + response.control.account_selector.as_deref().unwrap_or("balanced selection") + ); + println!("accounts: {}", response.accounts.len()); + + for account in &response.accounts { + let marker = if account.selected { "*" } else { "-" }; + let email = account.email.as_deref().unwrap_or("no email"); + + println!("{marker} {email} {} {}", account.account_fingerprint, account.status); + } + + Ok(()) +} + +fn print_use_response(response: &AccountUseResponse, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(response)?); + + return Ok(()); + } + + println!( + "Codex auth now uses {} ({})", + response.account.email.as_deref().unwrap_or("no email"), + response.account.account_fingerprint + ); + println!("auth: {}", response.codex_auth_path); + + Ok(()) +} + +fn create_login_home() -> Result { + let root = env::temp_dir().join(format!( + "decodex-codex-login-{}-{}", + process::id(), + OffsetDateTime::now_utc().unix_timestamp() + )); + + fs::create_dir_all(&root)?; + + secure_account_file(&root)?; + + Ok(root) +} + +fn default_codex_auth_json_path() -> Result { + if let Some(codex_home) = + env::var_os("CODEX_HOME").map(PathBuf::from).filter(|path| !path.as_os_str().is_empty()) + { + return Ok(codex_home.join("auth.json")); + } + + let Some(home) = env::var_os("HOME") else { + eyre::bail!("Failed to resolve `$HOME` for the Codex auth JSON path."); + }; + + Ok(PathBuf::from(home).join(".codex").join("auth.json")) +} + +fn write_auth_json_atomically(path: &Path, auth: &AuthDotJson) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + eyre::eyre!("Codex auth JSON path `{}` must have a parent directory.", path.display()) + })?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| eyre::eyre!("Codex auth JSON path must end in a valid file name."))?; + let temp_path = parent.join(format!(".{file_name}.tmp-{}", process::id())); + let mut output = serde_json::to_string_pretty(auth)?; + + output.push('\n'); + + fs::create_dir_all(parent)?; + fs::write(&temp_path, output)?; + + secure_account_file(&temp_path)?; + + fs::rename(temp_path, path)?; + + secure_account_file(path)?; + + Ok(()) +} + +fn cleanup_login_home(path: &Path, keep: bool) { + if keep { + eprintln!("temporary Codex login home preserved at {}", path.display()); + + return; + } + + if let Err(error) = fs::remove_dir_all(path) { + eprintln!( + "warning: failed to remove temporary Codex login home `{}`: {error}", + path.display() + ); + } +} + +fn secure_account_file(path: &Path) -> Result<()> { + #[cfg(unix)] + { + let mode = if path.is_dir() { 0o700 } else { 0o600 }; + let mut permissions = fs::metadata(path)?.permissions(); + + permissions.set_mode(mode); + + fs::set_permissions(path, permissions)?; + } + + Ok(()) +} + +fn ensure_toml_table<'a>(parent: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table> { + if !parent.contains_key(key) { + parent.insert(String::from(key), toml::Value::Table(toml::Table::new())); + } + + parent + .get_mut(key) + .and_then(toml::Value::as_table_mut) + .ok_or_else(|| eyre::eyre!("`{key}` in Decodex global config must be a table.")) +} + +fn first_nonblank_string(left: Option, right: Option) -> Option { + left.filter(|value| !value.trim().is_empty()) + .or_else(|| right.filter(|value| !value.trim().is_empty())) +} + +fn nonblank_string(value: Option<&str>) -> Option { + value.map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned) +} + +fn jwt_email_claim(id_token: Option<&str>) -> Option { + let payload = id_token?.split('.').nth(1)?; + let payload_bytes = parse_base64_url(payload)?; + let claims = serde_json::from_slice::(&payload_bytes).ok()?; + + claims.get("email").and_then(json_scalar_to_string) +} + +fn jwt_expiration_unix_epoch(jwt: &str) -> Option { + let payload = jwt.split('.').nth(1)?; + let payload_bytes = parse_base64_url(payload)?; + let claims = serde_json::from_slice::(&payload_bytes).ok()?; + + claims.get("exp").and_then(number_as_i64) +} + +fn parse_base64_url(input: &str) -> Option> { + let mut output = Vec::with_capacity(input.len() * 3 / 4); + let mut accumulator = 0_u32; + let mut bits = 0_u32; + + for byte in input.bytes().take_while(|byte| *byte != b'=') { + accumulator = (accumulator << 6) | u32::from(base64_url_value(byte)?); + bits += 6; + + if bits >= 8 { + bits -= 8; + + output.push(((accumulator >> bits) & 0xff) as u8); + } + } + + Some(output) +} + +const fn base64_url_value(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } +} + +fn json_scalar_to_string(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()), + serde_json::Value::Number(number) => Some(number.to_string()), + serde_json::Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +fn number_as_i64(value: &serde_json::Value) -> Option { + value + .as_i64() + .or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok())) + .or_else(|| value.as_f64().map(|number| number.round() as i64)) +} + +fn redact_account_id(account_id: &str) -> String { + let tail = + account_id.chars().rev().take(6).collect::>().into_iter().rev().collect::(); + + if tail.is_empty() { String::from("unknown") } else { format!("...{tail}") } +} + +fn now_rfc3339() -> Result { + Ok(OffsetDateTime::now_utc().format(&Rfc3339)?) +} + +const fn is_false(value: &bool) -> bool { + !*value +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use crate::accounts::{AccountPoolRecord, AccountStore, AuthDotJson, CodexTokenData}; + + #[test] + fn imports_auth_json_without_printing_tokens() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let auth_path = temp_dir.path().join("auth.json"); + let store = AccountStore::new( + temp_dir.path().join("accounts.jsonl"), + temp_dir.path().join("config.toml"), + ); + + fs::write( + &auth_path, + r#"{ + "email": "copy@example.com", + "tokens": { + "access_token": "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh_token": "refresh-secret", + "account_id": "acct_123456" + } + }"#, + ) + .expect("auth json should write"); + + let response = store.import_auth_json(&auth_path).expect("auth should import"); + let output = serde_json::to_string(&response).expect("response should serialize"); + + assert_eq!(response.accounts.len(), 1); + assert!(output.contains("copy@example.com")); + assert!(output.contains("...123456")); + assert!(!output.contains("refresh-secret")); + } + + #[test] + fn logout_removes_matching_account() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let store = AccountStore::new( + temp_dir.path().join("accounts.jsonl"), + temp_dir.path().join("config.toml"), + ); + + store + .save_records(&[AccountPoolRecord { + email: Some(String::from("copy@example.com")), + disabled: false, + cooldown_until_unix_epoch: None, + cooldown_until: None, + last_selected_at_unix_epoch: None, + auth_mode: None, + openai_api_key: None, + tokens: Some(CodexTokenData { + email: None, + id_token: None, + access_token: String::from("token"), + refresh_token: String::from("refresh"), + account_id: Some(String::from("acct_123456")), + }), + last_refresh: None, + }]) + .expect("records should save"); + + let response = store.logout("copy@example.com").expect("account should logout"); + + assert!(response.accounts.is_empty()); + assert_eq!(fs::read_to_string(&store.accounts_path).expect("accounts should read"), ""); + } + + #[test] + fn use_for_codex_overwrites_auth_json_from_pool() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let codex_auth_path = temp_dir.path().join(".codex/auth.json"); + let store = AccountStore::new_with_codex_auth_path( + temp_dir.path().join("accounts.jsonl"), + temp_dir.path().join("config.toml"), + codex_auth_path.clone(), + ); + + store + .save_records(&[account_record( + "copy@example.com", + "acct_123456", + "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh-secret", + )]) + .expect("records should save"); + + let response = store + .use_for_codex("copy@example.com", None) + .expect("account should become Codex auth"); + let auth_input = + fs::read_to_string(&codex_auth_path).expect("Codex auth should be written"); + let auth = + serde_json::from_str::(&auth_input).expect("Codex auth should parse"); + let tokens = auth.tokens.expect("Codex auth should include tokens"); + + assert_eq!(response.account.email.as_deref(), Some("copy@example.com")); + assert_eq!(auth.email.as_deref(), Some("copy@example.com")); + assert_eq!(tokens.account_id.as_deref(), Some("acct_123456")); + } + + #[test] + fn list_marks_codex_active_account() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let codex_auth_path = temp_dir.path().join("auth.json"); + let store = AccountStore::new_with_codex_auth_path( + temp_dir.path().join("accounts.jsonl"), + temp_dir.path().join("config.toml"), + codex_auth_path.clone(), + ); + + store + .save_records(&[ + account_record( + "copy@example.com", + "acct_123456", + "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh-secret", + ), + account_record( + "other@example.com", + "acct_654321", + "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh-secret-2", + ), + ]) + .expect("records should save"); + store.use_for_codex("other@example.com", None).expect("account should become Codex auth"); + + let response = store.list().expect("account list should load"); + + assert_eq!( + response.codex_auth.as_ref().and_then(|auth| auth.email.as_deref()), + Some("other@example.com") + ); + assert!(!response.accounts[0].codex_active); + assert!(response.accounts[1].codex_active); + } + + fn account_record( + email: &str, + account_id: &str, + access_token: &str, + refresh_token: &str, + ) -> AccountPoolRecord { + AccountPoolRecord { + email: Some(String::from(email)), + disabled: false, + cooldown_until_unix_epoch: None, + cooldown_until: None, + last_selected_at_unix_epoch: None, + auth_mode: None, + openai_api_key: None, + tokens: Some(CodexTokenData { + email: None, + id_token: None, + access_token: String::from(access_token), + refresh_token: String::from(refresh_token), + account_id: Some(String::from(account_id)), + }), + last_refresh: None, + } + } +} diff --git a/apps/decodex/src/app_bridge.rs b/apps/decodex/src/app_bridge.rs new file mode 100644 index 0000000..8170fea --- /dev/null +++ b/apps/decodex/src/app_bridge.rs @@ -0,0 +1,147 @@ +//! Internal JSON bridge used by the bundled Decodex App helper. + +use std::{ + io::{self, Read as _, Write as _}, + path::PathBuf, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + accounts::{self, AccountLoginRequest, AccountUseRequest}, + prelude::{Result, eyre}, +}; + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum AppBridgeRequest { + #[serde(rename = "account_list")] + List, + #[serde(rename = "account_select")] + Select { selector: String }, + #[serde(rename = "account_clear")] + Clear, + #[serde(rename = "account_logout")] + Logout { selector: String }, + #[serde(rename = "account_import")] + Import { auth_json_path: String }, + #[serde(rename = "account_use")] + Use { selector: String, auth_json_path: Option }, + #[serde(rename = "account_login")] + Login { + #[serde(default = "default_codex_bin")] + codex_bin: String, + #[serde(default)] + keep_temp_home: bool, + }, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum AppBridgeEvent<'a, T = Value> +where + T: Serialize, +{ + Output { text: &'a str }, + Result { payload: T }, + Error { message: String }, +} + +/// Run one Decodex App helper request from stdin and write JSON events to stdout. +pub fn run() -> Result<()> { + color_eyre::install()?; + + let mut input = String::new(); + + io::stdin().read_to_string(&mut input)?; + + let request = serde_json::from_str::(&input) + .map_err(|error| eyre::eyre!("Invalid Decodex App bridge request: {error}"))?; + + match handle_request(request) { + Ok(()) => Ok(()), + Err(error) => { + let event: AppBridgeEvent<'_, ()> = + AppBridgeEvent::Error { message: error.to_string() }; + + emit_event(&event)?; + + Err(error) + }, + } +} + +fn handle_request(request: AppBridgeRequest) -> Result<()> { + match request { + AppBridgeRequest::List => emit_result(&accounts::account_list()?), + AppBridgeRequest::Select { selector } => emit_result(&accounts::account_select(&selector)?), + AppBridgeRequest::Clear => emit_result(&accounts::account_clear()?), + AppBridgeRequest::Logout { selector } => emit_result(&accounts::account_logout(&selector)?), + AppBridgeRequest::Import { auth_json_path } => { + let auth_json_path = PathBuf::from(auth_json_path); + + emit_result(&accounts::account_import(&auth_json_path)?) + }, + AppBridgeRequest::Use { selector, auth_json_path } => + emit_result(&accounts::account_use(&AccountUseRequest { + selector, + auth_json_path: auth_json_path.map(Into::into), + json: true, + })?), + AppBridgeRequest::Login { codex_bin, keep_temp_home } => { + let response = accounts::account_login( + &AccountLoginRequest { codex_bin, keep_temp_home }, + |chunk| { + let event: AppBridgeEvent<'_, ()> = AppBridgeEvent::Output { text: chunk }; + + emit_event(&event) + }, + )?; + + emit_result(&response) + }, + } +} + +fn emit_result(payload: &T) -> Result<()> +where + T: Serialize, +{ + emit_event(&AppBridgeEvent::Result { payload }) +} + +fn emit_event(event: &AppBridgeEvent<'_, T>) -> Result<()> +where + T: Serialize, +{ + let mut stdout = io::stdout().lock(); + + serde_json::to_writer(&mut stdout, event)?; + + stdout.write_all(b"\n")?; + stdout.flush()?; + + Ok(()) +} + +fn default_codex_bin() -> String { + String::from("codex") +} + +#[cfg(test)] +mod tests { + use crate::app_bridge::AppBridgeRequest; + + #[test] + fn parses_account_use_bridge_request() { + let request = serde_json::from_value::(serde_json::json!({ + "operation": "account_use", + "selector": "copy@example.com", + "auth_json_path": "/tmp/auth.json" + })) + .expect("bridge request should parse"); + + assert!(matches!(request, AppBridgeRequest::Use { .. })); + } +} diff --git a/apps/decodex/src/bin/decodex-app-helper.rs b/apps/decodex/src/bin/decodex-app-helper.rs new file mode 100644 index 0000000..27dfc23 --- /dev/null +++ b/apps/decodex/src/bin/decodex-app-helper.rs @@ -0,0 +1,18 @@ +//! Decodex App helper entrypoint. + +#![allow(unused_crate_dependencies)] + +use std::process::ExitCode; + +use decodex::app_bridge; + +fn main() -> ExitCode { + match app_bridge::run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error:?}"); + + ExitCode::FAILURE + }, + } +} diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index 446e526..5e186fe 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -15,6 +15,7 @@ use clap::{ use serde::{Deserialize, Serialize}; use crate::{ + accounts::{self, AccountImportRequest, AccountLoginRequest, AccountUseRequest}, agent, archive_hygiene::{self, ArchiveHygieneRequest}, manual::{self, ManualCommitRequest, ManualLandRequest}, @@ -61,6 +62,7 @@ impl Cli { Command::Diagnose(args) => args.run(config_path), Command::Recover(args) => args.run(config_path), Command::ArchiveLinear(args) => args.run(config_path), + Command::Account(args) => args.run(), Command::Probe(args) => args.run(), Command::Attempt(args) => args.run(config_path), } @@ -135,6 +137,8 @@ enum Command { Recover(RecoverCommand), /// Dry-run or archive old terminal Linear issues by repo label. ArchiveLinear(ArchiveLinearCommand), + /// Manage the global Decodex Codex account pool. + Account(AccountCommand), /// Validate the local app-server integration boundary. Probe(ProbeCommand), /// Run one daemon-planned attempt from a structured request. @@ -142,6 +146,121 @@ enum Command { Attempt(AttemptCommand), } +#[derive(Debug, Args)] +struct AccountCommand { + #[command(subcommand)] + command: AccountSubcommand, +} +impl AccountCommand { + fn run(&self) -> crate::prelude::Result<()> { + match &self.command { + AccountSubcommand::List(args) => accounts::run_account_list(args.json), + AccountSubcommand::Select(args) => + accounts::run_account_select(&args.selector, args.json), + AccountSubcommand::Clear(args) => accounts::run_account_clear(args.json), + AccountSubcommand::Logout(args) => + accounts::run_account_logout(&args.selector, args.json), + AccountSubcommand::ImportAuth(args) => + accounts::run_account_import(&AccountImportRequest { + auth_json_path: args.auth_json.clone(), + json: args.json, + }), + AccountSubcommand::Use(args) => accounts::run_account_use(&AccountUseRequest { + selector: args.selector.clone(), + auth_json_path: args.auth_json.clone(), + json: args.json, + }), + AccountSubcommand::Login(args) => accounts::run_account_login(&AccountLoginRequest { + codex_bin: args.codex_bin.clone(), + keep_temp_home: args.keep_temp_home, + }), + } + } +} + +#[derive(Debug, Subcommand)] +enum AccountSubcommand { + /// List configured Codex accounts without printing token material. + List(AccountListCommand), + /// Pin new Decodex runs to one account. + Select(AccountSelectCommand), + /// Return new Decodex runs to balanced account selection. + Clear(AccountClearCommand), + /// Remove one account from the Decodex account pool. + Logout(AccountLogoutCommand), + /// Import an existing Codex `auth.json` into the Decodex account pool. + ImportAuth(AccountImportCommand), + /// Force Codex to use one stored account by overwriting its `auth.json`. + Use(AccountUseCommand), + /// Run Codex device login in an isolated temporary home, then import it. + Login(AccountLoginCommand), +} + +#[derive(Debug, Args)] +struct AccountListCommand { + /// Print the account list as JSON for app integrations. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct AccountSelectCommand { + /// Email, full account id, or redacted fingerprint to pin. + selector: String, + /// Print the updated account list as JSON for app integrations. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct AccountClearCommand { + /// Print the updated account list as JSON for app integrations. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct AccountLogoutCommand { + /// Email, full account id, or redacted fingerprint to remove. + selector: String, + /// Print the updated account list as JSON for app integrations. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct AccountImportCommand { + /// Path to a Codex `auth.json` file to import. + #[arg(value_name = "AUTH_JSON")] + auth_json: PathBuf, + /// Print the updated account list as JSON for app integrations. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct AccountUseCommand { + /// Email, full account id, or redacted fingerprint to write into Codex `auth.json`. + selector: String, + /// Override the Codex `auth.json` destination. Defaults to `$CODEX_HOME/auth.json` + /// or `~/.codex/auth.json`. + #[arg(long, value_name = "AUTH_JSON")] + auth_json: Option, + /// Print the updated Codex auth selection as JSON for app integrations. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct AccountLoginCommand { + /// Codex CLI binary used for isolated device login. + #[arg(long, default_value = "codex")] + codex_bin: String, + /// Keep the temporary Codex home after login for manual inspection. + #[arg(long)] + keep_temp_home: bool, +} + #[derive(Debug, Args)] struct CommitCommand { /// Tree-change summary for the new commit message. @@ -604,10 +723,11 @@ mod tests { use clap::Parser; use crate::cli::{ - AttemptCommand, Cli, Command, CommitCommand, DiagnoseCommand, LandCommand, ProbeCommand, - ProjectCommand, ProjectSubcommand, RecoverCommand, RecoverSubcommand, - ReviewHandoffDiagnoseCommand, ReviewHandoffRebindCommand, ReviewHandoffRecoveryCommand, - ReviewHandoffRecoverySubcommand, RunCommand, ServeCommand, StatusCommand, + AccountCommand, AccountSubcommand, AccountUseCommand, AttemptCommand, Cli, Command, + CommitCommand, DiagnoseCommand, LandCommand, ProbeCommand, ProjectCommand, + ProjectSubcommand, RecoverCommand, RecoverSubcommand, ReviewHandoffDiagnoseCommand, + ReviewHandoffRebindCommand, ReviewHandoffRecoveryCommand, ReviewHandoffRecoverySubcommand, + RunCommand, ServeCommand, StatusCommand, }; #[test] @@ -768,6 +888,30 @@ mod tests { )); } + #[test] + fn parses_account_use_with_auth_json_override() { + let cli = Cli::parse_from([ + "decodex", + "account", + "use", + "copy@example.com", + "--auth-json", + "./auth.json", + "--json", + ]); + + assert!(matches!( + cli.command, + Command::Account(AccountCommand { + command: AccountSubcommand::Use(AccountUseCommand { + selector, + auth_json: Some(_), + json: true, + }) + }) if selector == "copy@example.com" + )); + } + #[test] fn parses_hidden_attempt_with_stdin_request() { let cli = Cli::parse_from(["decodex", "--config", "./project.toml", "_attempt", "-"]); diff --git a/apps/decodex/src/lib.rs b/apps/decodex/src/lib.rs index 4d879c8..3698e65 100644 --- a/apps/decodex/src/lib.rs +++ b/apps/decodex/src/lib.rs @@ -1,9 +1,11 @@ //! Decodex runtime bootstrap and CLI entrypoint. +pub mod app_bridge; pub mod config; pub mod state; pub mod workflow; +mod accounts; mod agent; mod archive_hygiene; mod cli; diff --git a/assets/app-icon/composer/AppIcon.icon/Assets/app-icon-composer-layer.png b/assets/app-icon/composer/AppIcon.icon/Assets/app-icon-composer-layer.png new file mode 100644 index 0000000..f6bb02b Binary files /dev/null and b/assets/app-icon/composer/AppIcon.icon/Assets/app-icon-composer-layer.png differ diff --git a/assets/app-icon/composer/AppIcon.icon/icon.json b/assets/app-icon/composer/AppIcon.icon/icon.json new file mode 100644 index 0000000..fce138b --- /dev/null +++ b/assets/app-icon/composer/AppIcon.icon/icon.json @@ -0,0 +1,38 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "blend-mode" : "normal", + "fill" : "automatic", + "image-name" : "app-icon-composer-layer.png", + "name" : "decodex-prompt-cloud-bolt", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.42 + }, + "translucency" : { + "enabled" : true, + "value" : 0.45 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/assets/app-icon/generated/app-icon-default-preview.png b/assets/app-icon/generated/app-icon-default-preview.png new file mode 100644 index 0000000..94ad5be Binary files /dev/null and b/assets/app-icon/generated/app-icon-default-preview.png differ diff --git a/assets/app-icon/generated/app-icon-flat.png b/assets/app-icon/generated/app-icon-flat.png new file mode 100644 index 0000000..e5426b7 Binary files /dev/null and b/assets/app-icon/generated/app-icon-flat.png differ diff --git a/assets/app-icon/generated/app-icon.icns b/assets/app-icon/generated/app-icon.icns new file mode 100644 index 0000000..5345f11 Binary files /dev/null and b/assets/app-icon/generated/app-icon.icns differ diff --git a/assets/app-icon/source/app-icon-design.md b/assets/app-icon/source/app-icon-design.md new file mode 100644 index 0000000..f4a3657 --- /dev/null +++ b/assets/app-icon/source/app-icon-design.md @@ -0,0 +1,15 @@ +# Decodex App Icon + +Purpose: Source notes for the generated Decodex App icon. + +The app icon uses a purpose-built Decodex/Codex mark: a large modern cloud prompt with +a lightning mark entering from the right edge. The `>_` prompt connects it to Codex, +while the lightning suggests quick account switching and active control without relying +on text. + +The flat `.icns` output is a local-development export; the composer lane keeps the +foreground layer separate so an Icon Composer pass can rebuild the same +prompt-cloud-bolt mark as a layered Liquid Glass icon package. Treat the checked-in +`.icns` as the packaged flat fallback, not the final multi-layer Liquid Glass artifact. + +Generator: `scripts/assets/render_decodex_app_icons.swift`. diff --git a/assets/icon-source.png b/assets/icon-source.png deleted file mode 100644 index f335bbc..0000000 Binary files a/assets/icon-source.png and /dev/null differ diff --git a/assets/logo-source.png b/assets/logo-source.png deleted file mode 100644 index 71e6d27..0000000 Binary files a/assets/logo-source.png and /dev/null differ diff --git a/assets/tray-icon/generated/tray-icon-template.png b/assets/tray-icon/generated/tray-icon-template.png new file mode 100644 index 0000000..ce93621 Binary files /dev/null and b/assets/tray-icon/generated/tray-icon-template.png differ diff --git a/assets/tray-icon/source/tray-icon-design.md b/assets/tray-icon/source/tray-icon-design.md new file mode 100644 index 0000000..16a414b --- /dev/null +++ b/assets/tray-icon/source/tray-icon-design.md @@ -0,0 +1,10 @@ +# Decodex Menu Bar Icon + +Purpose: Source notes for the generated Decodex App menu bar template image. + +The menu bar icon uses the same cloud-wrapped prompt-lightning mark as the app icon, +but removes the tile and color. It is generated as a single-color macOS template image +with a moderate 22pt display size so the system can tint it correctly in light, dark, +and selected menu bar states. + +Generator: `scripts/assets/render_decodex_app_icons.swift`. diff --git a/docs/reference/workspace-layout.md b/docs/reference/workspace-layout.md index ab87a06..ef9fce0 100644 --- a/docs/reference/workspace-layout.md +++ b/docs/reference/workspace-layout.md @@ -17,6 +17,7 @@ should not be treated as repository source. | Path | Role | | --- | --- | | `apps/decodex/` | Rust package that builds the `decodex` CLI and runtime. Runtime, orchestration, tracker integration, app-server integration, operator HTTP, and local control-plane behavior live under `apps/decodex/src/`. | +| `apps/decodex-app/` | SwiftPM macOS app for local Decodex Codex account-pool management. It talks to the bundled `decodex-app-helper`, which links the Rust account service directly, and does not own runtime scheduling or operator dashboard state. | | `site/` | Astro static site for the public Decodex signal surface. It renders checked-in content and generated JSON from `site/src/content/`; it is not backed by a live Decodex daemon. | | `scripts/github/` | Deterministic GitHub collection, normalization, render, validation, and sync scripts for public signal content. | | `scripts/config/` | Repository automation scripts for config-derived artifacts. | @@ -32,7 +33,7 @@ should not be treated as repository source. | `docs/research/` | Machine-authored research run artifacts used by shipped research tooling. | | `docs/plans/` | Historical saved plan artifacts from the static-site bootstrap. These are not primary authority. | | `dev/` | Local development helpers outside `dev/skills/`, such as the operator dashboard mock server. | -| `assets/` | Shared static assets that are not owned by the Astro app's generated output. | +| `assets/` | Shared static assets that are not owned by the Astro app's generated output. Decodex App icons live under `assets/app-icon/{source,composer,generated}/`; menu bar template assets live under `assets/tray-icon/{source,generated}/`; `scripts/assets/render_decodex_app_icons.swift` regenerates the icon set. | | `.github/` | CI, release, Pages deployment, and content-refresh workflows. | | `Makefile.toml` | Repo-native task names and automation entrypoints. | | `decodex.example.toml` | Redacted template for a project `project.toml`; live project contracts live under `~/.codex/decodex/projects//`. | @@ -163,4 +164,5 @@ structure: - `site/.astro/`: Astro local cache - `.worktrees/`: local Git worktree lanes - `.workspaces/`: local clone-backed workspace lanes from older workflows -- `.codex/`: local agent/runtime state +- `.codex/`: local agent/runtime state, except the app-local + `apps/decodex-app/.codex/environments/environment.toml` run action config diff --git a/scripts/assets/render_decodex_app_icons.swift b/scripts/assets/render_decodex_app_icons.swift new file mode 100755 index 0000000..339b9d1 --- /dev/null +++ b/scripts/assets/render_decodex_app_icons.swift @@ -0,0 +1,291 @@ +#!/usr/bin/env swift + +import AppKit +import CoreGraphics +import Foundation + +let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) +let appIconGenerated = root.appendingPathComponent("assets/app-icon/generated") +let appIconComposerAssets = root.appendingPathComponent("assets/app-icon/composer/AppIcon.icon/Assets") +let trayIconGenerated = root.appendingPathComponent("assets/tray-icon/generated") + +for directory in [appIconGenerated, appIconComposerAssets, trayIconGenerated] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) +} + +let canvasSize = 1024 +let appIconURL = appIconGenerated.appendingPathComponent("app-icon-flat.png") +let previewURL = appIconGenerated.appendingPathComponent("app-icon-default-preview.png") +let composerLayerURL = appIconComposerAssets.appendingPathComponent("app-icon-composer-layer.png") +let trayIconURL = trayIconGenerated.appendingPathComponent("tray-icon-template.png") +let icnsURL = appIconGenerated.appendingPathComponent("app-icon.icns") + +enum Palette { + static let fieldTop = NSColor(calibratedRed: 0.110, green: 0.145, blue: 0.230, alpha: 1) + static let fieldBottom = NSColor(calibratedRed: 0.030, green: 0.050, blue: 0.090, alpha: 1) + static let cloudTop = NSColor(calibratedRed: 0.965, green: 0.985, blue: 1.000, alpha: 1) + static let cloudBottom = NSColor(calibratedRed: 0.700, green: 0.760, blue: 0.845, alpha: 1) + static let ink = NSColor(calibratedRed: 0.155, green: 0.175, blue: 0.230, alpha: 1) + static let bolt = NSColor(calibratedRed: 1.000, green: 0.760, blue: 0.230, alpha: 1) + static let boltCore = NSColor(calibratedRed: 1.000, green: 0.925, blue: 0.410, alpha: 1) + static let white = NSColor(calibratedWhite: 1.0, alpha: 1) + static let black = NSColor(calibratedWhite: 0.0, alpha: 1) +} + +func bitmap(size: Int = canvasSize, drawing: (CGContext) -> Void) throws -> NSBitmapImageRep { + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: size, + pixelsHigh: size, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + throw NSError(domain: "DecodexIconRender", code: 1) + } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + NSGraphicsContext.current?.cgContext.setShouldAntialias(true) + NSGraphicsContext.current?.cgContext.setAllowsAntialiasing(true) + NSGraphicsContext.current?.cgContext.interpolationQuality = .high + drawing(NSGraphicsContext.current!.cgContext) + NSGraphicsContext.restoreGraphicsState() + + return rep +} + +func writePNG(_ rep: NSBitmapImageRep, to url: URL) throws { + guard let data = rep.representation(using: .png, properties: [:]) else { + throw NSError(domain: "DecodexIconRender", code: 2) + } + try data.write(to: url) +} + +func roundedRect(_ rect: NSRect, radius: CGFloat) -> NSBezierPath { + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) +} + +func fillRoundedRect(_ rect: NSRect, radius: CGFloat, color: NSColor, alpha: CGFloat = 1) { + color.withAlphaComponent(alpha).setFill() + roundedRect(rect, radius: radius).fill() +} + +func strokePath(_ points: [NSPoint], color: NSColor, width: CGFloat, alpha: CGFloat = 1) { + guard let first = points.first else { return } + let path = NSBezierPath() + path.lineWidth = width + path.lineCapStyle = .round + path.lineJoinStyle = .round + path.move(to: first) + for point in points.dropFirst() { + path.line(to: point) + } + color.withAlphaComponent(alpha).setStroke() + path.stroke() +} + +func fillPolygon(_ points: [NSPoint], color: NSColor, alpha: CGFloat = 1) { + guard let first = points.first else { return } + let path = NSBezierPath() + path.move(to: first) + for point in points.dropFirst() { + path.line(to: point) + } + path.close() + color.withAlphaComponent(alpha).setFill() + path.fill() +} + +func boltPoints(center: NSPoint, scale: CGFloat) -> [NSPoint] { + [ + NSPoint(x: center.x + 22 * scale, y: center.y + 138 * scale), + NSPoint(x: center.x - 92 * scale, y: center.y + 18 * scale), + NSPoint(x: center.x - 18 * scale, y: center.y + 18 * scale), + NSPoint(x: center.x - 58 * scale, y: center.y - 142 * scale), + NSPoint(x: center.x + 104 * scale, y: center.y - 12 * scale), + NSPoint(x: center.x + 22 * scale, y: center.y - 12 * scale), + ] +} + +func appBoltPoints(offsetX: CGFloat = 0, offsetY: CGFloat = 0) -> [NSPoint] { + [ + NSPoint(x: 850 + offsetX, y: 592 + offsetY), + NSPoint(x: 718 + offsetX, y: 426 + offsetY), + NSPoint(x: 800 + offsetX, y: 426 + offsetY), + NSPoint(x: 756 + offsetX, y: 268 + offsetY), + NSPoint(x: 904 + offsetX, y: 444 + offsetY), + NSPoint(x: 832 + offsetX, y: 444 + offsetY), + ] +} + +func appBoltCorePoints() -> [NSPoint] { + [ + NSPoint(x: 838, y: 542), + NSPoint(x: 764, y: 438), + NSPoint(x: 808, y: 438), + NSPoint(x: 784, y: 334), + NSPoint(x: 850, y: 436), + NSPoint(x: 814, y: 436), + ] +} + +func drawTile() { + let tile = roundedRect(NSRect(x: 58, y: 58, width: 908, height: 908), radius: 222) + NSGradient(colors: [Palette.fieldTop, Palette.fieldBottom])!.draw(in: tile, angle: -48) + + Palette.white.withAlphaComponent(0.11).setStroke() + let rim = roundedRect(NSRect(x: 82, y: 82, width: 860, height: 860), radius: 198) + rim.lineWidth = 4 + rim.stroke() +} + +func cloudPath() -> NSBezierPath { + let path = NSBezierPath() + path.append(NSBezierPath(ovalIn: NSRect(x: 120, y: 372, width: 332, height: 332))) + path.append(NSBezierPath(ovalIn: NSRect(x: 270, y: 448, width: 374, height: 374))) + path.append(NSBezierPath(ovalIn: NSRect(x: 492, y: 338, width: 326, height: 326))) + path.append(NSBezierPath(ovalIn: NSRect(x: 170, y: 244, width: 322, height: 322))) + path.append(NSBezierPath(ovalIn: NSRect(x: 354, y: 232, width: 370, height: 370))) + path.append(roundedRect(NSRect(x: 198, y: 280, width: 570, height: 360), radius: 170)) + return path +} + +func drawCloudContainer() { + let shadow = NSShadow() + shadow.shadowBlurRadius = 42 + shadow.shadowOffset = NSSize(width: 0, height: -24) + shadow.shadowColor = NSColor(calibratedWhite: 0, alpha: 0.30) + shadow.set() + Palette.black.withAlphaComponent(0.24).setFill() + cloudPath().fill() + NSShadow().set() + + NSGradient(colors: [Palette.cloudTop, Palette.cloudBottom])!.draw(in: cloudPath(), angle: -58) +} + +func drawPromptMark(color: NSColor, width: CGFloat, alpha: CGFloat = 1) { + strokePath( + [NSPoint(x: 292, y: 590), NSPoint(x: 382, y: 506), NSPoint(x: 292, y: 422)], + color: color, + width: width, + alpha: alpha + ) + strokePath( + [NSPoint(x: 472, y: 418), NSPoint(x: 620, y: 418)], + color: color, + width: width, + alpha: alpha + ) +} + +func drawAppBolt() { + fillPolygon(appBoltPoints(), color: Palette.bolt) + fillPolygon(appBoltCorePoints(), color: Palette.boltCore, alpha: 0.54) +} + +func drawAppMark() { + drawCloudContainer() + drawAppBolt() + drawPromptMark(color: Palette.ink, width: 52) +} + +func drawTemplateBolt() { + fillPolygon(boltPoints(center: NSPoint(x: 762, y: 386), scale: 2.04), color: .black) +} + +func drawTemplateCloud() { + Palette.black.setFill() + cloudPath().fill() +} + +func clearTemplatePrompt() { + let context = NSGraphicsContext.current!.cgContext + context.saveGState() + context.setBlendMode(.clear) + drawPromptMark(color: .clear, width: 108) + context.restoreGState() +} + +func drawTemplateMark() { + let context = NSGraphicsContext.current!.cgContext + context.saveGState() + context.translateBy(x: 512, y: 512) + context.scaleBy(x: 1.10, y: 1.10) + context.translateBy(x: -512, y: -512) + drawTemplateBolt() + drawTemplateCloud() + clearTemplatePrompt() + context.restoreGState() +} + +func drawAppIcon() throws -> NSBitmapImageRep { + try bitmap { _ in + drawTile() + drawAppMark() + } +} + +func drawComposerLayer() throws -> NSBitmapImageRep { + try bitmap { _ in + drawAppMark() + } +} + +func drawTrayIcon() throws -> NSBitmapImageRep { + try bitmap { _ in + drawTemplateMark() + } +} + +func scaledPNG(from source: NSBitmapImageRep, size: Int, to url: URL) throws { + let rep = try bitmap(size: size) { ctx in + ctx.interpolationQuality = .high + ctx.draw(source.cgImage!, in: CGRect(x: 0, y: 0, width: size, height: size)) + } + try writePNG(rep, to: url) +} + +func buildICNS(from source: NSBitmapImageRep) throws { + let temp = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("decodex-app-icon-\(UUID().uuidString)") + let iconset = temp.appendingPathComponent("AppIcon.iconset") + try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true) + + let sizes: [(String, Int)] = [ + ("icon_16x16.png", 16), + ("icon_16x16@2x.png", 32), + ("icon_32x32.png", 32), + ("icon_32x32@2x.png", 64), + ("icon_128x128.png", 128), + ("icon_128x128@2x.png", 256), + ("icon_256x256.png", 256), + ("icon_256x256@2x.png", 512), + ("icon_512x512.png", 512), + ("icon_512x512@2x.png", 1024), + ] + for (name, size) in sizes { + try scaledPNG(from: source, size: size, to: iconset.appendingPathComponent(name)) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") + process.arguments = ["-c", "icns", iconset.path, "-o", icnsURL.path] + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + throw NSError(domain: "DecodexIconRender", code: Int(process.terminationStatus)) + } + try FileManager.default.removeItem(at: temp) +} + +let appIcon = try drawAppIcon() +try writePNG(appIcon, to: appIconURL) +try scaledPNG(from: appIcon, size: 256, to: previewURL) +try buildICNS(from: appIcon) +try writePNG(try drawComposerLayer(), to: composerLayerURL) +try writePNG(try drawTrayIcon(), to: trayIconURL)