diff --git a/Cargo.lock b/Cargo.lock index 7d9d84271..2a8f54375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,6 +424,7 @@ dependencies = [ "tempfile", "time", "toml", + "toml_edit", "tracing", "tracing-appender", "tracing-subscriber", @@ -1957,10 +1958,19 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.2", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -1972,13 +1982,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.2", ] [[package]] @@ -2553,6 +2576,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 811a9ea71..dd428bc84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ sha1 = { version = "0.11" } tempfile = { version = "3.27" } time = { version = "0.3", features = ["formatting"] } toml = { version = "1.1" } +toml_edit = { version = "0.23" } tracing = { version = "0.1" } tracing-appender = { version = "0.2" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 6bcda2118..33e03d580 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -2,7 +2,7 @@ import AppKit import Foundation import SwiftUI -private enum PanelFont { +enum PanelFont { private static func text( _ size: CGFloat, weight: Font.Weight, @@ -28,7 +28,7 @@ private enum PanelFont { static let iconButton = text(11, weight: .semibold) } -private enum PanelPalette { +enum PanelPalette { static func primaryText(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color(red: 0.95, green: 0.96, blue: 0.98).opacity(0.97) @@ -77,6 +77,12 @@ private enum PanelPalette { : Color(red: 0.62, green: 0.36, blue: 0.14) } + static func fastModeAccent(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.98, green: 0.84, blue: 0.48) + : Color(red: 0.42, green: 0.31, blue: 0.09) + } + static func destructive(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color(red: 1, green: 0.42, blue: 0.45) @@ -96,7 +102,7 @@ private enum PanelPalette { } } -private enum PanelMotion { +enum PanelMotion { static let hover = Animation.interactiveSpring(response: 0.22, dampingFraction: 0.86, blendDuration: 0.04) static let press = Animation.interactiveSpring(response: 0.16, dampingFraction: 0.78, blendDuration: 0.02) static let state = Animation.interactiveSpring(response: 0.24, dampingFraction: 0.88, blendDuration: 0.05) @@ -216,8 +222,9 @@ struct AccountPanelView: View { @ObservedObject var store: AccountStore @ObservedObject var loginWindowState: LoginWindowState @Environment(\.colorScheme) private var colorScheme - @Environment(\.openWindow) private var openWindow @State private var pendingLogout: CodexAccount? + @State private var armedLogoutAccountID: String? + @State private var logoutArmToken = UUID() @AppStorage("decodex.operator.accountPrivacy") private var accountPrivacy = AccountPrivacy.hiddenValue var body: some View { @@ -237,13 +244,15 @@ struct AccountPanelView: View { set: { visible in if !visible { pendingLogout = nil + disarmLogout() } } ), titleVisibility: .visible ) { if let account = pendingLogout { - Button("Log Out \(displayName(for: account))", role: .destructive) { + Button("Remove \(displayName(for: account))", role: .destructive) { + disarmLogout() Task { await store.logout(account) } @@ -254,6 +263,10 @@ struct AccountPanelView: View { Text("This removes \(displayName(for: account)) from the Decodex account pool on this Mac.") } } + .background { + LoginPanelPresenter(store: store, state: loginWindowState) + .frame(width: 0, height: 0) + } } private var panelContent: some View { @@ -320,6 +333,20 @@ struct AccountPanelView: View { help: emailsHidden ? "Show account emails" : "Hide account emails" ) + PanelIconButtonView( + symbol: store.fastModeEnabled ? "bolt.fill" : "bolt", + tint: PanelPalette.fastModeAccent(colorScheme), + isActive: store.fastModeEnabled, + isDisabled: store.isSettingFastMode, + size: 25, + action: { + Task { + await store.setFastMode(!store.fastModeEnabled) + } + }, + help: store.fastModeEnabled ? "Turn fast mode off" : "Turn fast mode on" + ) + if hasFixedSelection { PanelIconButtonView( symbol: "shuffle", @@ -342,7 +369,7 @@ struct AccountPanelView: View { isPrimary: true, size: 25, action: { - presentLogin(.add) + presentLogin(.newAccount) }, help: "Add login" ) @@ -435,6 +462,7 @@ struct AccountPanelView: View { account: account, emailsHidden: emailsHidden, showsDivider: index < store.accounts.count - 1, + isLogoutArmed: armedLogoutAccountID == account.id, useInCodex: { Task { await store.useInCodex(account) @@ -445,11 +473,11 @@ struct AccountPanelView: View { await store.select(account) } }, - reauthenticate: { - presentLogin(.reauth(displayName(for: account))) + login: { + presentLogin(.account(displayName(for: account))) }, logout: { - pendingLogout = account + requestLogout(account) } ) } @@ -539,11 +567,42 @@ struct AccountPanelView: View { } } + private func requestLogout(_ account: CodexAccount) { + if armedLogoutAccountID == account.id { + pendingLogout = account + disarmLogout() + return + } + + let token = UUID() + logoutArmToken = token + withAnimation(PanelMotion.state) { + armedLogoutAccountID = account.id + } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_400_000_000) + guard logoutArmToken == token, armedLogoutAccountID == account.id else { + return + } + withAnimation(PanelMotion.state) { + armedLogoutAccountID = nil + } + } + } + + private func disarmLogout() { + logoutArmToken = UUID() + withAnimation(PanelMotion.state) { + armedLogoutAccountID = nil + } + } + private func presentLogin(_ mode: AccountLoginSheetMode) { loginWindowState.mode = mode store.resetLoginSession() NSApp.activate(ignoringOtherApps: true) - openWindow(id: DecodexWindowID.login) + loginWindowState.isPresented = true } } @@ -551,9 +610,10 @@ struct AccountRowView: View { let account: CodexAccount let emailsHidden: Bool let showsDivider: Bool + let isLogoutArmed: Bool let useInCodex: () -> Void let routeRunsHere: () -> Void - let reauthenticate: () -> Void + let login: () -> Void let logout: () -> Void @Environment(\.colorScheme) private var colorScheme @@ -604,8 +664,8 @@ struct AccountRowView: View { isActive: false, isPrimary: true, size: 21, - action: reauthenticate, - help: "Re-authenticate account" + action: login, + help: "Login account" ) } else { PanelIconButtonView( @@ -634,15 +694,16 @@ struct AccountRowView: View { ) PanelIconButtonView( - symbol: "trash", + symbol: isLogoutArmed ? "trash.fill" : "trash", tint: PanelPalette.destructive(colorScheme), - isActive: false, + isActive: isLogoutArmed, isDestructive: true, - isSubtle: true, + isSubtle: !isLogoutArmed, size: 21, action: logout, - help: "Remove account" + help: isLogoutArmed ? "Click again to confirm removal" : "Remove account" ) + .modifier(DeleteArmedShakeModifier(isArmed: isLogoutArmed)) } } @@ -665,6 +726,7 @@ struct AccountRowView: View { } .animation(PanelMotion.state, value: account.selected) .animation(PanelMotion.state, value: account.codexActive) + .animation(PanelMotion.state, value: isLogoutArmed) } private var displayName: String { @@ -676,7 +738,7 @@ struct AccountRowView: View { return "Restore balanced run routing" } if account.needsLogin { - return "Re-authenticate before routing runs" + return "Login before routing runs" } if account.disabled { return "Disabled account cannot route runs" @@ -1110,6 +1172,36 @@ struct PanelIconButtonView: View { } } +private struct DeleteArmedShakeModifier: ViewModifier { + let isArmed: Bool + @State private var shakeTrigger = 0 + + func body(content: Content) -> some View { + content + .modifier(DeleteShakeEffect(animatableData: CGFloat(shakeTrigger))) + .scaleEffect(isArmed ? 1.045 : 1) + .onChange(of: isArmed) { _, armed in + guard armed else { + return + } + withAnimation(.linear(duration: 0.42)) { + shakeTrigger += 1 + } + } + } +} + +private struct DeleteShakeEffect: GeometryEffect { + var travel: CGFloat = 1.8 + var shakesPerUnit: CGFloat = 3 + var animatableData: CGFloat + + func effectValue(size: CGSize) -> ProjectionTransform { + let xOffset = travel * sin(animatableData * .pi * shakesPerUnit * 2) + return ProjectionTransform(CGAffineTransform(translationX: xOffset, y: 0)) + } +} + private enum AccountPrivacy { static let hiddenValue = "hidden" static let visibleValue = "visible" @@ -1220,14 +1312,14 @@ private enum AccountDisplay { } } -private enum GlassSurfaceDepth { +enum GlassSurfaceDepth { case panel case section case row case control } -private extension View { +extension View { func modernGlassSurface( cornerRadius: CGFloat, depth: GlassSurfaceDepth = .section @@ -1241,7 +1333,7 @@ private extension View { } } -private struct ModernGlassSurfaceModifier: ViewModifier { +struct ModernGlassSurfaceModifier: ViewModifier { @Environment(\.colorScheme) private var colorScheme let cornerRadius: CGFloat let depth: GlassSurfaceDepth @@ -1266,7 +1358,7 @@ private struct ModernGlassSurfaceModifier: ViewModifier { @available(macOS 26.0, *) private var configuredGlass: Glass { - var glass = Glass.clear + var glass = Glass.regular.tint(glassTint) if depth == .control { glass = glass.interactive() } @@ -1274,6 +1366,27 @@ private struct ModernGlassSurfaceModifier: ViewModifier { return glass } + private var glassTint: Color? { + switch depth { + case .panel: + return colorScheme == .dark + ? Color(red: 0.66, green: 0.74, blue: 0.86).opacity(0.06) + : Color.white.opacity(0.05) + case .section: + return colorScheme == .dark + ? Color(red: 0.72, green: 0.8, blue: 0.92).opacity(0.1) + : Color.white.opacity(0.08) + case .row: + return colorScheme == .dark + ? Color(red: 0.7, green: 0.78, blue: 0.9).opacity(0.08) + : Color.white.opacity(0.06) + case .control: + return colorScheme == .dark + ? Color(red: 0.78, green: 0.86, blue: 0.98).opacity(0.14) + : Color.white.opacity(0.11) + } + } + private var materialStyle: AnyShapeStyle { switch depth { case .panel: diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index b04a598bb..7f6963493 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -3,8 +3,10 @@ import Foundation @MainActor final class AccountStore: ObservableObject { @Published private(set) var accountList: AccountListResponse? + @Published private(set) var fastMode: CodexFastModeResponse? @Published private(set) var isRefreshing = false @Published private(set) var isLoggingIn = false + @Published private(set) var isSettingFastMode = false @Published var loginTranscript = "" @Published var notice: String? @@ -23,6 +25,10 @@ final class AccountStore: ObservableObject { accountList?.accounts ?? [] } + var fastModeEnabled: Bool { + fastMode?.enabled == true + } + var modeLabel: String { guard let control = accountList?.control else { return "Not loaded" @@ -74,6 +80,7 @@ final class AccountStore: ObservableObject { as: AccountListResponse.self ) notice = nil + await refreshFastMode() } catch { notice = error.localizedDescription } @@ -157,6 +164,33 @@ final class AccountStore: ObservableObject { } } + func setFastMode(_ enabled: Bool) async { + guard !isSettingFastMode else { + return + } + + let previous = fastMode + fastMode = CodexFastModeResponse( + codexConfigPath: previous?.codexConfigPath ?? "", + enabled: enabled + ) + isSettingFastMode = true + defer { + isSettingFastMode = false + } + + do { + fastMode = try await bridge.runJSON( + .codexFastModeSet(enabled: enabled), + as: CodexFastModeResponse.self + ) + notice = nil + } catch { + fastMode = previous + notice = error.localizedDescription + } + } + func logout(_ account: CodexAccount) async { do { accountList = try await bridge.runJSON( @@ -179,12 +213,24 @@ final class AccountStore: ObservableObject { self?.loginTranscript += chunk } notice = nil + await refreshFastMode() } catch { notice = error.localizedDescription } isLoggingIn = false } + + private func refreshFastMode() async { + do { + fastMode = try await bridge.runJSON( + .codexFastModeStatus, + as: CodexFastModeResponse.self + ) + } catch { + notice = error.localizedDescription + } + } } private extension AccountListResponse { diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift index a080ee13d..dd42a9f10 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift @@ -20,13 +20,10 @@ enum AppAssets { }() } -enum DecodexWindowID { - static let login = "decodex-login" -} - @MainActor final class LoginWindowState: ObservableObject { - @Published var mode = AccountLoginSheetMode.add + @Published var mode = AccountLoginSheetMode.newAccount + @Published var isPresented = false } @main @@ -57,12 +54,6 @@ struct DecodexApp: App { } } .menuBarExtraStyle(.window) - - Window("Decodex Login", id: DecodexWindowID.login) { - LoginSheetView(store: store, mode: loginWindowState.mode) - .background(FloatingLoginWindowConfigurator()) - } - .windowResizability(.contentSize) } @ViewBuilder diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift index 606105353..7c18f4a5d 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift @@ -28,6 +28,7 @@ struct AppBridgeRequest: Encodable, Sendable { let keepTempHome: Bool? let includeUsage: Bool? let forceRefresh: Bool? + let enabled: Bool? enum CodingKeys: String, CodingKey { case operation @@ -36,6 +37,8 @@ struct AppBridgeRequest: Encodable, Sendable { case codexBin = "codex_bin" case keepTempHome = "keep_temp_home" case includeUsage = "include_usage" + case forceRefresh = "force_refresh" + case enabled } static func accountList(forceRefresh: Bool = false) -> AppBridgeRequest { @@ -64,6 +67,12 @@ struct AppBridgeRequest: Encodable, Sendable { AppBridgeRequest(operation: "account_login", includeUsage: true) } + static let codexFastModeStatus = AppBridgeRequest(operation: "codex_fast_mode_status") + + static func codexFastModeSet(enabled: Bool) -> AppBridgeRequest { + AppBridgeRequest(operation: "codex_fast_mode_set", enabled: enabled) + } + private init( operation: String, selector: String? = nil, @@ -71,7 +80,8 @@ struct AppBridgeRequest: Encodable, Sendable { codexBin: String? = nil, keepTempHome: Bool? = nil, includeUsage: Bool? = nil, - forceRefresh: Bool? = nil + forceRefresh: Bool? = nil, + enabled: Bool? = nil ) { self.operation = operation self.selector = selector @@ -80,6 +90,7 @@ struct AppBridgeRequest: Encodable, Sendable { self.keepTempHome = keepTempHome self.includeUsage = includeUsage self.forceRefresh = forceRefresh + self.enabled = enabled } } @@ -192,7 +203,7 @@ struct DecodexAppBridge: Sendable { if onOutput == nil, try request.serverRoute() != nil { return try await DecodexServerBridge.shared.run(request, as: type) } - guard request.operation == "account_login" else { + guard request.requiresHelper else { throw DecodexAppBridgeError.invalidResponse( "operation \(request.operation) must be served by Decodex server" ) @@ -278,3 +289,14 @@ struct DecodexAppBridge: Sendable { ) } } + +private extension AppBridgeRequest { + var requiresHelper: Bool { + switch operation { + case "account_login", "codex_fast_mode_status", "codex_fast_mode_set": + return true + default: + return false + } + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift index 5ef6c10c0..b7f95de6e 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift @@ -6,6 +6,12 @@ struct ServerRoute { let body: Data? } +private enum DecodexServerProbe: Equatable { + case live + case reachable(String) + case unreachable +} + actor DecodexServerBridge { static let shared = DecodexServerBridge() @@ -78,28 +84,44 @@ actor DecodexServerBridge { return serverBaseURL } - if let serverBaseURL, await isLive(serverBaseURL) { + if let serverBaseURL, await probeServer(serverBaseURL) == .live { noteLive(serverBaseURL) return serverBaseURL } - if let configured = configuredServerURL(), await isLive(configured) { - noteLive(configured) - - return configured + if let configured = configuredServerURL() { + switch await probeServer(configured) { + case .live: + noteLive(configured) + + return configured + case .reachable(let reason): + throw DecodexAppBridgeError.invalidResponse( + "Decodex server at \(configured.absoluteString) is reachable but not app-compatible: \(reason)" + ) + case .unreachable: + break + } } - if await isLive(defaultBaseURL) { + switch await probeServer(defaultBaseURL) { + case .live: noteLive(defaultBaseURL) return defaultBaseURL + case .reachable(let reason): + throw DecodexAppBridgeError.invalidResponse( + "Port \(defaultListenAddress) already has a server, but it is not app-compatible: \(reason). Decodex App will not start its bundled server over an existing process." + ) + case .unreachable: + break } try startBundledServer() for _ in 0..<40 { - if await isLive(defaultBaseURL) { + if await probeServer(defaultBaseURL) == .live { noteLive(defaultBaseURL) return defaultBaseURL @@ -142,6 +164,10 @@ actor DecodexServerBridge { } private func isLive(_ baseURL: URL) async -> Bool { + await probeServer(baseURL) == .live + } + + private func probeServer(_ baseURL: URL) async -> DecodexServerProbe { let url = baseURL.appendingPathComponent("livez") var request = URLRequest(url: url) @@ -149,11 +175,17 @@ actor DecodexServerBridge { do { let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return .reachable("non-HTTP response from /livez") + } let body = String(decoding: data, as: UTF8.self) + if httpResponse.statusCode == 200 && body.trimmingCharacters(in: .whitespacesAndNewlines) == "ok" { + return .live + } - return (response as? HTTPURLResponse)?.statusCode == 200 && body == "ok" + return .reachable("/livez returned HTTP \(httpResponse.statusCode)") } catch { - return false + return .unreachable } } diff --git a/apps/decodex-app/Sources/DecodexApp/LoginPanelPresenter.swift b/apps/decodex-app/Sources/DecodexApp/LoginPanelPresenter.swift new file mode 100644 index 000000000..04d9e1481 --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/LoginPanelPresenter.swift @@ -0,0 +1,180 @@ +import AppKit +import SwiftUI + +struct LoginPanelPresenter: NSViewRepresentable { + @ObservedObject var store: AccountStore + @ObservedObject var state: LoginWindowState + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + context.coordinator.hostView = view + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.hostView = nsView + context.coordinator.update(store: store, state: state) + } + + final class Coordinator: NSObject, NSWindowDelegate { + weak var hostView: NSView? + private weak var state: LoginWindowState? + private var panel: LoginFloatingPanel? + private var hostingView: NSHostingView? + private var isClosingProgrammatically = false + + @MainActor + func update(store: AccountStore, state: LoginWindowState) { + self.state = state + + guard state.isPresented else { + closePanel() + return + } + + let rootView = makeRootView(store: store, state: state) + if let panel, let hostingView { + hostingView.rootView = rootView + resizeAndPlace(panel, hostingView: hostingView) + show(panel) + return + } + + let hostingView = NSHostingView(rootView: rootView) + hostingView.frame = NSRect(origin: .zero, size: NSSize(width: 328, height: 220)) + + let panel = LoginFloatingPanel( + contentRect: hostingView.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.delegate = self + panel.contentView = hostingView + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false + panel.becomesKeyOnlyIfNeeded = true + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.animationBehavior = .utilityWindow + panel.collectionBehavior = [.transient, .fullScreenAuxiliary, .canJoinAllSpaces] + + self.panel = panel + self.hostingView = hostingView + resizeAndPlace(panel, hostingView: hostingView) + show(panel) + } + + @MainActor + private func makeRootView(store: AccountStore, state: LoginWindowState) -> LoginSheetView { + LoginSheetView( + store: store, + mode: state.mode, + onCancel: { [weak state] in + state?.isPresented = false + }, + onComplete: { [weak state] in + state?.isPresented = false + } + ) + } + + @MainActor + private func closePanel() { + guard let panel else { + return + } + + isClosingProgrammatically = true + panel.parent?.removeChildWindow(panel) + panel.close() + isClosingProgrammatically = false + self.panel = nil + hostingView = nil + } + + @MainActor + private func resizeAndPlace(_ panel: NSPanel, hostingView: NSHostingView) { + hostingView.layoutSubtreeIfNeeded() + let fittingSize = hostingView.fittingSize + let panelSize = NSSize( + width: max(328, fittingSize.width), + height: max(190, fittingSize.height) + ) + panel.setContentSize(panelSize) + panel.setFrameOrigin(origin(for: panelSize)) + } + + @MainActor + private func show(_ panel: NSPanel) { + guard let parentWindow = hostView?.window else { + panel.orderFrontRegardless() + return + } + + if panel.parent !== parentWindow { + panel.parent?.removeChildWindow(panel) + parentWindow.addChildWindow(panel, ordered: .above) + } + panel.level = parentWindow.level + panel.orderFrontRegardless() + } + + @MainActor + private func origin(for panelSize: NSSize) -> NSPoint { + let parentWindow = hostView?.window + let screen = parentWindow?.screen ?? NSScreen.main + let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800) + let margin: CGFloat = 8 + + var x: CGFloat + var y: CGFloat + if let parentFrame = parentWindow?.frame { + x = parentFrame.midX - panelSize.width / 2 + y = parentFrame.maxY - panelSize.height - 68 + } else { + let mouse = NSEvent.mouseLocation + x = mouse.x - panelSize.width / 2 + y = mouse.y - panelSize.height - 18 + } + + x = min(max(x, visibleFrame.minX + margin), visibleFrame.maxX - panelSize.width - margin) + y = min(max(y, visibleFrame.minY + margin), visibleFrame.maxY - panelSize.height - margin) + + return NSPoint(x: x, y: y) + } + + func windowWillClose(_ notification: Notification) { + if let panel { + panel.parent?.removeChildWindow(panel) + } + panel = nil + hostingView = nil + guard !isClosingProgrammatically else { + return + } + + Task { @MainActor [weak state] in + state?.isPresented = false + } + } + } +} + +private final class LoginFloatingPanel: NSPanel { + override var canBecomeKey: Bool { + true + } + + override var canBecomeMain: Bool { + false + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift index 068351a96..461483a23 100644 --- a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift +++ b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift @@ -2,32 +2,22 @@ import AppKit import SwiftUI enum AccountLoginSheetMode: Equatable { - case add - case reauth(String) + case newAccount + case account(String) var title: String { - switch self { - case .add: - return "Device Login" - case .reauth: - return "Re-auth Account" - } + "Login" } var icon: String { - switch self { - case .add: - return "person.crop.circle.badge.plus" - case .reauth: - return "person.crop.circle.badge.exclamationmark" - } + "person.crop.circle.badge.plus" } func subtitle(fallback: String, isActive: Bool) -> String { switch self { - case .add: + case .newAccount: return fallback - case .reauth(let name): + case .account(let name): return !isActive && !name.isEmpty ? name : fallback } } @@ -37,16 +27,40 @@ struct LoginSheetView: View { @ObservedObject var store: AccountStore let mode: AccountLoginSheetMode @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) private var dismiss @State private var requestStarted = false - - init(store: AccountStore, mode: AccountLoginSheetMode = .add) { + @State private var copyFeedback = false + @State private var copyFeedbackToken = UUID() + @State private var openFeedback = false + @State private var openFeedbackToken = UUID() + private let onCancel: () -> Void + private let onComplete: () -> Void + + init( + store: AccountStore, + mode: AccountLoginSheetMode = .newAccount, + onCancel: @escaping () -> Void = {}, + onComplete: @escaping () -> Void = {} + ) { self.store = store self.mode = mode + self.onCancel = onCancel + self.onComplete = onComplete } var body: some View { - VStack(alignment: .leading, spacing: 11) { + Group { + if #available(macOS 26.0, *) { + GlassEffectContainer(spacing: 7) { + content + } + } else { + content + } + } + } + + private var content: some View { + VStack(alignment: .leading, spacing: 7) { header codeCard @@ -65,19 +79,11 @@ struct LoginSheetView: View { actions } - .frame(width: 322) - .padding(14) - .background { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.regularMaterial) - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(LoginPalette.panelTint(colorScheme)) - } - .overlay { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(LoginPalette.panelStroke(colorScheme), lineWidth: 0.65) - } - .shadow(color: .black.opacity(colorScheme == .dark ? 0.3 : 0.12), radius: 20, y: 10) + .frame(width: 310) + .padding(9) + .modernGlassSurface(cornerRadius: 18, depth: .panel) + .controlSize(.small) + .symbolRenderingMode(.hierarchical) .onChange(of: store.loginPrompt) { _, prompt in if prompt != nil { requestStarted = false @@ -93,17 +99,10 @@ struct LoginSheetView: View { private var header: some View { HStack(spacing: 8) { Image(systemName: mode.icon) - .font(.system(size: 12.5, weight: .semibold)) + .font(LoginFont.icon) .foregroundStyle(LoginPalette.accent(colorScheme)) - .frame(width: 27, height: 27) - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(LoginPalette.controlTint(colorScheme)) - } - .overlay { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(LoginPalette.controlStroke(colorScheme), lineWidth: 0.55) - } + .frame(width: 28, height: 28) + .modernGlassSurface(cornerRadius: 9, depth: .control) VStack(alignment: .leading, spacing: 1) { Text(mode.title) @@ -123,6 +122,7 @@ struct LoginSheetView: View { Spacer() } + .padding(.bottom, 1) } private var requestStatus: some View { @@ -135,16 +135,9 @@ struct LoginSheetView: View { .foregroundStyle(LoginPalette.secondaryText(colorScheme)) Spacer(minLength: 0) } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(LoginPalette.statusTint(colorScheme)) - } - .overlay { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .strokeBorder(LoginPalette.cardStroke(colorScheme), lineWidth: 0.5) - } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .modernGlassSurface(cornerRadius: 9, depth: .section) } private var codeCard: some View { @@ -153,50 +146,41 @@ struct LoginSheetView: View { HStack(spacing: 6) { Text(loginDestinationLabel) - .font(LoginFont.caption) + .font(LoginFont.destination) .foregroundStyle(LoginPalette.secondaryText(colorScheme)) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 4) - Button { - copyCode() - } label: { - Image(systemName: "doc.on.doc") - .frame(width: 22, height: 20) - } - .buttonStyle(LoginSmallButtonStyle()) - .disabled(store.loginPrompt == nil) - .help("Copy code") - - Button { - openVerificationURL() - } label: { - Image(systemName: "arrow.up.forward.app") - .frame(width: 22, height: 20) - } - .buttonStyle(LoginSmallButtonStyle()) - .disabled(store.loginPrompt?.verificationURL == nil) - .help("Open browser") + LoginIconActionButton( + symbol: "doc.on.doc", + feedbackSymbol: "checkmark", + isFeedbackActive: copyFeedback, + isEnabled: store.loginPrompt != nil, + action: copyCode, + help: copyFeedback ? "Copied" : "Copy code" + ) + + LoginIconActionButton( + symbol: "arrow.up.forward.app", + feedbackSymbol: nil, + isFeedbackActive: openFeedback, + isEnabled: store.loginPrompt?.verificationURL != nil, + action: openVerificationURL, + help: openFeedback ? "Opened browser" : "Open browser" + ) } } - .padding(9) - .background { - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(LoginPalette.cardTint(colorScheme)) - } - .overlay { - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(LoginPalette.cardStroke(colorScheme), lineWidth: 0.55) - } + .padding(8) + .modernGlassSurface(cornerRadius: 10, depth: .section) } private var actions: some View { HStack(spacing: 7) { Button("Cancel") { requestStarted = false - dismiss() + onCancel() } .keyboardShortcut(.cancelAction) .buttonStyle(LoginTextButtonStyle()) @@ -209,7 +193,7 @@ struct LoginSheetView: View { await store.login() requestStarted = false if store.notice == nil { - dismiss() + onComplete() } } } label: { @@ -257,6 +241,7 @@ struct LoginSheetView: View { NSPasteboard.general.clearContents() NSPasteboard.general.setString(code, forType: .string) + showCopyFeedback() } private func openVerificationURL() { @@ -264,33 +249,43 @@ struct LoginSheetView: View { return } + showOpenFeedback() NSWorkspace.shared.open(url) } -} -struct FloatingLoginWindowConfigurator: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - let view = NSView() - configureSoon(from: view) - return view - } + private func showCopyFeedback() { + let token = UUID() + copyFeedbackToken = token + withAnimation(PanelMotion.state) { + copyFeedback = true + } - func updateNSView(_ nsView: NSView, context: Context) { - configureSoon(from: nsView) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 850_000_000) + guard copyFeedbackToken == token else { + return + } + withAnimation(PanelMotion.state) { + copyFeedback = false + } + } } - private func configureSoon(from view: NSView) { - DispatchQueue.main.async { - guard let window = view.window else { + private func showOpenFeedback() { + let token = UUID() + openFeedbackToken = token + withAnimation(PanelMotion.state) { + openFeedback = true + } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 650_000_000) + guard openFeedbackToken == token else { return } - - window.level = .floating - window.collectionBehavior.insert(.fullScreenAuxiliary) - window.isMovableByWindowBackground = true - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.styleMask.insert(.fullSizeContentView) + withAnimation(PanelMotion.state) { + openFeedback = false + } } } } @@ -312,14 +307,7 @@ private struct LoginCodeBoxesView: View { .monospacedDigit() .foregroundStyle(LoginPalette.primaryText(colorScheme)) .frame(width: 22, height: 30) - .background { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(LoginPalette.codeCellTint(colorScheme, isEmpty: code.isEmpty)) - } - .overlay { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(LoginPalette.codeCellStroke(colorScheme), lineWidth: 0.55) - } + .modernGlassSurface(cornerRadius: 7, depth: .control) } } .frame(maxWidth: .infinity, alignment: .center) @@ -342,23 +330,98 @@ private struct LoginCodeBoxesView: View { } } +private struct LoginIconActionButton: View { + let symbol: String + let feedbackSymbol: String? + let isFeedbackActive: Bool + let isEnabled: Bool + let action: () -> Void + let help: String + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: isFeedbackActive ? (feedbackSymbol ?? symbol) : symbol) + .frame(width: 23, height: 22) + } + .buttonStyle( + LoginSmallButtonStyle( + isProminent: isHovered, + isFeedbackActive: isFeedbackActive + ) + ) + .disabled(!isEnabled) + .onHover { hovering in + withAnimation(PanelMotion.hover) { + isHovered = hovering + } + } + .help(help) + } +} + private struct LoginSmallButtonStyle: ButtonStyle { + var isProminent = false + var isFeedbackActive = false @Environment(\.colorScheme) private var colorScheme @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { configuration.label .font(LoginFont.icon) - .foregroundStyle(LoginPalette.accent(colorScheme).opacity(isEnabled ? 0.9 : 0.34)) - .background { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(LoginPalette.controlTint(colorScheme).opacity(configuration.isPressed ? 0.72 : 1)) - } + .foregroundStyle(foreground) + .modernGlassSurface(cornerRadius: 8, depth: .control) .overlay { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(LoginPalette.controlStroke(colorScheme), lineWidth: 0.5) + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(stroke, lineWidth: 0.55) + .allowsHitTesting(false) } - .opacity(configuration.isPressed ? 0.78 : 1) + .shadow( + color: shadowColor, + radius: isFeedbackActive ? 4 : (isProminent ? 2.4 : 0), + y: isFeedbackActive ? 1.2 : (isProminent ? 0.8 : 0) + ) + .scaleEffect(configuration.isPressed ? 0.91 : (isFeedbackActive ? 1.055 : 1)) + .opacity(isEnabled ? 1 : 0.38) + .animation(PanelMotion.press, value: configuration.isPressed) + .animation(PanelMotion.hover, value: isProminent) + .animation(PanelMotion.state, value: isFeedbackActive) + } + + private var foreground: Color { + if !isEnabled { + return LoginPalette.secondaryText(colorScheme).opacity(0.62) + } + if isFeedbackActive { + return LoginPalette.feedback(colorScheme) + } + if isProminent { + return LoginPalette.accent(colorScheme).opacity(colorScheme == .dark ? 1 : 0.95) + } + return LoginPalette.secondaryText(colorScheme).opacity(colorScheme == .dark ? 0.9 : 0.78) + } + + private var stroke: Color { + if !isEnabled { + return LoginPalette.secondaryText(colorScheme).opacity(0.08) + } + if isFeedbackActive { + return LoginPalette.feedback(colorScheme).opacity(colorScheme == .dark ? 0.36 : 0.26) + } + if isProminent { + return LoginPalette.accent(colorScheme).opacity(colorScheme == .dark ? 0.22 : 0.18) + } + return LoginPalette.secondaryText(colorScheme).opacity(colorScheme == .dark ? 0.1 : 0.12) + } + + private var shadowColor: Color { + if isFeedbackActive { + return LoginPalette.feedback(colorScheme).opacity(colorScheme == .dark ? 0.22 : 0.16) + } + if isProminent { + return Color.black.opacity(colorScheme == .dark ? 0.22 : 0.1) + } + return .clear } } @@ -371,17 +434,12 @@ private struct LoginTextButtonStyle: ButtonStyle { configuration.label .font(LoginFont.button) .foregroundStyle(foreground) - .padding(.horizontal, 11) - .frame(height: 25) - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(background.opacity(configuration.isPressed ? 0.72 : 1)) - } - .overlay { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(stroke, lineWidth: 0.55) - } - .opacity(isEnabled ? 1 : 0.46) + .padding(.horizontal, isPrimary ? 11 : 9) + .frame(height: 24) + .modernGlassSurface(cornerRadius: 12, depth: .control) + .scaleEffect(configuration.isPressed ? 0.965 : 1) + .opacity(isEnabled ? 1 : 0.64) + .animation(.interactiveSpring(response: 0.16, dampingFraction: 0.78), value: configuration.isPressed) } private var foreground: Color { @@ -392,117 +450,41 @@ private struct LoginTextButtonStyle: ButtonStyle { return LoginPalette.secondaryText(colorScheme) } - private var background: Color { - isPrimary ? LoginPalette.primaryButtonTint(colorScheme) : LoginPalette.controlTint(colorScheme) - } - - private var stroke: Color { - isPrimary ? LoginPalette.primaryButtonStroke(colorScheme) : LoginPalette.controlStroke(colorScheme) - } } private enum LoginFont { - static let title = Font.system(size: 13.4, weight: .semibold) - static let caption = Font.system(size: 9.6, weight: .medium) - static let button = Font.system(size: 10.3, weight: .semibold) - static let icon = Font.system(size: 10.5, weight: .medium) - static let code = Font.system(size: 15.8, weight: .semibold, design: .monospaced) + static let title = Font.system(size: 14.6, weight: .semibold) + static let caption = Font.system(size: 10.6, weight: .medium) + static let destination = Font.system(size: 10.8, weight: .semibold) + static let button = Font.system(size: 10.8, weight: .semibold) + static let icon = Font.system(size: 11.4, weight: .semibold) + static let code = Font.system(size: 16.2, weight: .semibold, design: .monospaced) } private enum LoginPalette { static func primaryText(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.9, green: 0.94, blue: 0.99).opacity(0.95) - : Color(red: 0.12, green: 0.17, blue: 0.24).opacity(0.92) + ? Color(red: 0.98, green: 0.985, blue: 1).opacity(0.99) + : Color(red: 0.09, green: 0.11, blue: 0.15).opacity(0.96) } static func secondaryText(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.68, green: 0.76, blue: 0.86).opacity(0.78) - : Color(red: 0.34, green: 0.43, blue: 0.55).opacity(0.72) + ? Color(red: 0.86, green: 0.89, blue: 0.95).opacity(0.94) + : Color(red: 0.28, green: 0.33, blue: 0.4).opacity(0.86) } static func accent(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.72, green: 0.84, blue: 0.98) - : Color(red: 0.16, green: 0.34, blue: 0.54) + PanelPalette.actionBlue(colorScheme) } static func warning(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.88, green: 0.58, blue: 0.35) - : Color(red: 0.58, green: 0.31, blue: 0.17) - } - - static func panelTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.13, green: 0.2, blue: 0.3).opacity(0.42) - : Color(red: 0.72, green: 0.84, blue: 0.95).opacity(0.34) - } - - static func panelStroke(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color.white.opacity(0.18) - : Color.white.opacity(0.58) - } - - static func cardTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.72, green: 0.84, blue: 0.98).opacity(0.095) - : Color.white.opacity(0.62) - } - - static func cardStroke(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color.white.opacity(0.13) - : Color(red: 0.42, green: 0.58, blue: 0.76).opacity(0.2) - } - - static func statusTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.62, green: 0.78, blue: 1).opacity(0.11) - : Color(red: 0.86, green: 0.94, blue: 1).opacity(0.62) - } - - static func controlTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.7, green: 0.8, blue: 0.92).opacity(0.15) - : Color.white.opacity(0.74) - } - - static func controlStroke(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color.white.opacity(0.14) - : Color(red: 0.36, green: 0.52, blue: 0.72).opacity(0.22) - } - - static func codeCellTint(_ colorScheme: ColorScheme, isEmpty: Bool) -> Color { - if isEmpty { - return colorScheme == .dark - ? Color.white.opacity(0.045) - : Color.white.opacity(0.38) - } - - return colorScheme == .dark - ? Color(red: 0.72, green: 0.84, blue: 0.98).opacity(0.16) - : Color(red: 0.9, green: 0.96, blue: 1).opacity(0.84) - } - - static func codeCellStroke(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color.white.opacity(0.16) - : Color.white.opacity(0.66) - } - - static func primaryButtonTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.56, green: 0.7, blue: 0.9).opacity(0.18) - : Color(red: 0.88, green: 0.95, blue: 1).opacity(0.84) + PanelPalette.warning(colorScheme) } - static func primaryButtonStroke(_ colorScheme: ColorScheme) -> Color { + static func feedback(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.8, green: 0.9, blue: 1).opacity(0.2) - : Color(red: 0.34, green: 0.54, blue: 0.72).opacity(0.3) + ? Color(red: 0.86, green: 0.93, blue: 1) + : Color(red: 0.13, green: 0.32, blue: 0.52) } } diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index 8d04791b1..12572ce1b 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -56,6 +56,16 @@ struct AccountControl: Decodable { } } +struct CodexFastModeResponse: Decodable, Equatable { + let codexConfigPath: String + let enabled: Bool + + enum CodingKeys: String, CodingKey { + case codexConfigPath = "codex_config_path" + case enabled + } +} + struct CodexAccount: Decodable, Identifiable, Equatable { let accountFingerprint: String let email: String? diff --git a/apps/decodex/Cargo.toml b/apps/decodex/Cargo.toml index 38946d7a5..3d59ce4bf 100644 --- a/apps/decodex/Cargo.toml +++ b/apps/decodex/Cargo.toml @@ -33,6 +33,7 @@ serde_json = { workspace = true } sha1 = { workspace = true } time = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/apps/decodex/src/app_bridge.rs b/apps/decodex/src/app_bridge.rs index 4b583b571..5cdaadbd6 100644 --- a/apps/decodex/src/app_bridge.rs +++ b/apps/decodex/src/app_bridge.rs @@ -10,6 +10,7 @@ use serde_json::Value; use crate::{ accounts::{self, AccountListResponse, AccountLoginRequest, AccountUseRequest}, + codex_config, prelude::{Result, eyre}, }; @@ -55,6 +56,10 @@ enum AppBridgeRequest { #[serde(default)] include_usage: bool, }, + #[serde(rename = "codex_fast_mode_status")] + FastModeStatus, + #[serde(rename = "codex_fast_mode_set")] + FastModeSet { enabled: bool }, } #[derive(Serialize)] @@ -129,6 +134,9 @@ fn handle_request(request: AppBridgeRequest) -> Result<()> { emit_account_list_result(response, include_usage) }, + AppBridgeRequest::FastModeStatus => emit_result(&codex_config::fast_mode_status()?), + AppBridgeRequest::FastModeSet { enabled } => + emit_result(&codex_config::set_fast_mode(enabled)?), } } @@ -179,4 +187,15 @@ mod tests { assert!(matches!(request, AppBridgeRequest::Use { .. })); } + + #[test] + fn parses_fast_mode_set_bridge_request() { + let request = serde_json::from_value::(serde_json::json!({ + "operation": "codex_fast_mode_set", + "enabled": true + })) + .expect("bridge request should parse"); + + assert!(matches!(request, AppBridgeRequest::FastModeSet { enabled: true })); + } } diff --git a/apps/decodex/src/codex_config.rs b/apps/decodex/src/codex_config.rs new file mode 100644 index 000000000..a7ffb003a --- /dev/null +++ b/apps/decodex/src/codex_config.rs @@ -0,0 +1,213 @@ +//! Mutations for the user-level Codex configuration file. + +#[cfg(unix)] use std::os::unix::fs::PermissionsExt as _; +use std::{ + env, fs, + io::ErrorKind, + path::{Path, PathBuf}, + process, +}; + +use serde::Serialize; +use toml_edit::{self, DocumentMut, Item, Table}; + +use crate::prelude::{Result, eyre}; + +#[derive(Debug, Serialize)] +pub(crate) struct CodexFastModeResponse { + pub(crate) codex_config_path: String, + pub(crate) enabled: bool, +} + +pub(crate) fn fast_mode_status() -> Result { + let path = codex_config_path()?; + + fast_mode_status_at_path(&path) +} + +pub(crate) fn set_fast_mode(enabled: bool) -> Result { + let path = codex_config_path()?; + + set_fast_mode_at_path(&path, enabled) +} + +fn fast_mode_status_at_path(path: &Path) -> Result { + let document = load_codex_config_document(path)?; + + Ok(CodexFastModeResponse { + codex_config_path: path.display().to_string(), + enabled: fast_mode_enabled(&document), + }) +} + +fn set_fast_mode_at_path(path: &Path, enabled: bool) -> Result { + let mut document = load_codex_config_document(path)?; + let root = document.as_table_mut(); + + if !root.contains_key("features") { + root.insert("features", Item::Table(Table::new())); + } + + let features = root + .get_mut("features") + .and_then(Item::as_table_like_mut) + .ok_or_else(|| eyre::eyre!("`features` in Codex config must be a TOML table."))?; + + features.insert("fast_mode", toml_edit::value(enabled)); + + write_codex_config_document(path, &document)?; + + Ok(CodexFastModeResponse { codex_config_path: path.display().to_string(), enabled }) +} + +fn fast_mode_enabled(document: &DocumentMut) -> bool { + document + .get("features") + .and_then(Item::as_table_like) + .and_then(|features| features.get("fast_mode")) + .and_then(Item::as_bool) + .unwrap_or(false) +} + +fn load_codex_config_document(path: &Path) -> Result { + let input = match fs::read_to_string(path) { + Ok(input) => input, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(DocumentMut::new()), + Err(error) => { + eyre::bail!("Failed to read Codex config `{}`: {error}", path.display()); + }, + }; + + if input.trim().is_empty() { + return Ok(DocumentMut::new()); + } + + input + .parse::() + .map_err(|error| eyre::eyre!("Codex config `{}` is invalid TOML: {error}", path.display())) +} + +fn write_codex_config_document(path: &Path, document: &DocumentMut) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + eyre::eyre!("Codex config 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 config path must end in a valid file name."))?; + let temp_path = parent.join(format!(".{file_name}.tmp-{}", process::id())); + let mut output = document.to_string(); + + if !output.ends_with('\n') { + output.push('\n'); + } + + fs::create_dir_all(parent)?; + fs::write(&temp_path, output)?; + + secure_config_file(&temp_path)?; + + fs::rename(temp_path, path)?; + + secure_config_file(path)?; + + Ok(()) +} + +fn codex_config_path() -> Result { + Ok(codex_home_dir()?.join("config.toml")) +} + +fn codex_home_dir() -> Result { + if let Some(codex_home) = env::var_os("CODEX_HOME") { + let path = PathBuf::from(codex_home); + + if !path.as_os_str().is_empty() { + return Ok(path); + } + } + + let Some(home) = env::var_os("HOME") else { + eyre::bail!("Failed to resolve `$HOME` for the Codex config path."); + }; + + Ok(PathBuf::from(home).join(".codex")) +} + +fn secure_config_file(path: &Path) -> Result<()> { + #[cfg(unix)] + { + let mut permissions = fs::metadata(path)?.permissions(); + + permissions.set_mode(0o600); + + fs::set_permissions(path, permissions)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + #[test] + fn missing_config_reports_fast_mode_disabled() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let path = temp.path().join("config.toml"); + let response = super::fast_mode_status_at_path(&path).expect("status should load"); + + assert_eq!(response.codex_config_path, path.display().to_string()); + assert!(!response.enabled); + } + + #[test] + fn set_fast_mode_creates_features_table_and_overwrites_value() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let path = temp.path().join("config.toml"); + let enabled = super::set_fast_mode_at_path(&path, true).expect("fast mode should enable"); + + assert!(enabled.enabled); + + let disabled = + super::set_fast_mode_at_path(&path, false).expect("fast mode should disable"); + let output = fs::read_to_string(&path).expect("config should be written"); + + assert!(!disabled.enabled); + assert!(output.contains("[features]")); + assert!(output.contains("fast_mode = false")); + } + + #[test] + fn set_fast_mode_preserves_unrelated_config() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let path = temp.path().join("config.toml"); + + fs::write( + &path, + "# local Codex settings\nmodel = \"gpt-5.2\"\n\n[features]\nplugins = true\nfast_mode = false\n", + ) + .expect("seed config should write"); + super::set_fast_mode_at_path(&path, true).expect("fast mode should enable"); + + let output = fs::read_to_string(&path).expect("config should be written"); + + assert!(output.contains("# local Codex settings")); + assert!(output.contains("model = \"gpt-5.2\"")); + assert!(output.contains("plugins = true")); + assert!(output.contains("fast_mode = true")); + } + + #[test] + fn set_fast_mode_rejects_non_table_features() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let path = temp.path().join("config.toml"); + + fs::write(&path, "features = true\n").expect("seed config should write"); + + let error = + super::set_fast_mode_at_path(&path, true).expect_err("features must be a table"); + + assert!(error.to_string().contains("`features` in Codex config must be a TOML table")); + } +} diff --git a/apps/decodex/src/lib.rs b/apps/decodex/src/lib.rs index 3698e65d9..308b8c177 100644 --- a/apps/decodex/src/lib.rs +++ b/apps/decodex/src/lib.rs @@ -9,6 +9,7 @@ mod accounts; mod agent; mod archive_hygiene; mod cli; +mod codex_config; mod commit_message; mod default_branch_sync; mod git_credentials;