Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile.toml
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,13 @@ 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 -x "$APP_PATH/Contents/Helpers/decodex"
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 --verify --strict "$APP_PATH/Contents/Helpers/decodex"
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'
Expand Down
22 changes: 15 additions & 7 deletions apps/decodex-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ 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:
The first Decodex App release manages the shared Codex account pool through the local
Decodex server. On launch the app connects to an existing `decodex serve` on the
default local endpoint when one is available; otherwise it starts the bundled
`decodex serve --api-only` binary and talks to that server. App-started servers do not
poll registered projects or dispatch Linear work. The helper remains available for
interactive login flows that need streamed command output:

- list accounts without printing token material
- pin future Decodex runs to one account
Expand Down Expand Up @@ -44,13 +48,17 @@ 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:
The staging script builds the Swift app, the Rust `decodex` server binary, and
`decodex-app-helper`, then copies both Rust executables into `Contents/Helpers/`.
Direct SwiftPM launches are development-only; when needed, point them at workspace-built
executables:

```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
cargo build -p decodex --bin decodex
DECODEX_APP_DECODEX="$(pwd)/target/debug/decodex" \
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
Expand All @@ -73,4 +81,4 @@ 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.
signed `decodex` / `decodex-app-helper` executables into the app bundle.
94 changes: 90 additions & 4 deletions apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ struct AccountPanelView: View {
NoticeView(text: notice)
}

if store.accounts.isEmpty {
if let usageProbeError = store.accountList?.usageProbeError {
NoticeView(text: "Usage probe: \(usageProbeError)")
}

if store.isInitialLoading {
loadingState
} else if store.accounts.isEmpty {
emptyState
} else {
accountList
Expand Down Expand Up @@ -99,7 +105,7 @@ struct AccountPanelView: View {

Button {
Task {
await store.refresh()
await store.refresh(force: true)
}
} label: {
Image(systemName: store.isRefreshing ? "arrow.triangle.2.circlepath.circle" : "arrow.clockwise")
Expand Down Expand Up @@ -148,6 +154,19 @@ struct AccountPanelView: View {
.modernGlassSurface(cornerRadius: 14)
}

private var loadingState: some View {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Loading accounts")
.font(.subheadline.weight(.semibold))
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.modernGlassSurface(cornerRadius: 14)
}

private var accountList: some View {
ScrollView {
LazyVStack(spacing: 6) {
Expand Down Expand Up @@ -252,8 +271,13 @@ struct AccountPanelView: View {
}

private var accountListHeight: CGFloat {
min(
CGFloat(store.accounts.count) * 50 + CGFloat(max(store.accounts.count - 1, 0)) * 6 + 2,
let rows = store.accounts.reduce(CGFloat(0)) { total, account in
total + (account.hasUsageWindowData ? 72 : 50)
}
let spacing = CGFloat(max(store.accounts.count - 1, 0)) * 6 + 2

return min(
rows + spacing,
286
)
}
Expand Down Expand Up @@ -296,11 +320,35 @@ struct AccountRowView: View {
.lineLimit(1)
.truncationMode(.middle)
Text("·")
if let planLabel = account.planLabel {
Text(planLabel)
.lineLimit(1)
Text("·")
}
Text(account.statusLabel)
.lineLimit(1)
}
.font(.caption)
.foregroundStyle(.secondary)

if account.hasUsageWindowData {
HStack(spacing: 6) {
AccountUsageBadgeView(
label: account.windowLabel(seconds: account.primaryWindowSeconds),
remainingPercent: account.primaryRemainingPercent,
tone: account.usageTone(
remainingPercent: account.primaryRemainingPercent
)
)
AccountUsageBadgeView(
label: account.windowLabel(seconds: account.secondaryWindowSeconds),
remainingPercent: account.secondaryRemainingPercent,
tone: account.usageTone(
remainingPercent: account.secondaryRemainingPercent
)
)
}
}
}

Spacer()
Expand Down Expand Up @@ -359,6 +407,44 @@ struct AccountRowView: View {
}
}

struct AccountUsageBadgeView: View {
let label: String
let remainingPercent: Int?
let tone: AccountTone

var body: some View {
HStack(spacing: 4) {
Text(label)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
Text(remainingText)
.font(.caption2.monospacedDigit().weight(.semibold))
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(color.opacity(0.16), in: Capsule())
}

private var remainingText: String {
guard let remainingPercent else {
return "n/a"
}

return "\(remainingPercent)%"
}

private var color: Color {
switch tone {
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

Expand Down
23 changes: 21 additions & 2 deletions apps/decodex-app/Sources/DecodexApp/AccountStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ final class AccountStore: ObservableObject {

private let bridge = DecodexAppBridge()

var isInitialLoading: Bool {
accountList == nil && isRefreshing
}

var accounts: [CodexAccount] {
accountList?.accounts ?? []
}
Expand Down Expand Up @@ -42,20 +46,35 @@ final class AccountStore: ObservableObject {
return "person.2.circle"
}

func refresh() async {
func refresh(force: Bool = false) async {
guard !isRefreshing else {
return
}

isRefreshing = true
defer {
isRefreshing = false
}

do {
accountList = try await bridge.runJSON(.accountList, as: AccountListResponse.self)
accountList = try await bridge.runJSON(
.accountList(forceRefresh: force),
as: AccountListResponse.self
)
notice = nil
} catch {
notice = error.localizedDescription
}
}

func refreshIfNeeded() async {
guard accountList == nil else {
return
}

await refresh()
}

func useInCodex(_ account: CodexAccount) async {
do {
_ = try await bridge.runJSON(
Expand Down
14 changes: 12 additions & 2 deletions apps/decodex-app/Sources/DecodexApp/DecodexApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,23 @@ private enum AppAssets {
@main
struct DecodexApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var store = AccountStore()
@StateObject private var store: AccountStore

@MainActor
init() {
let accountStore = AccountStore()

_store = StateObject(wrappedValue: accountStore)
Task {
await accountStore.refreshIfNeeded()
}
}

var body: some Scene {
MenuBarExtra {
AccountPanelView(store: store)
.task {
await store.refresh()
await store.refreshIfNeeded()
}
} label: {
Label {
Expand Down
39 changes: 31 additions & 8 deletions apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ enum DecodexAppBridgeError: LocalizedError {
case .launchFailed(let message):
return message
case .commandFailed(let code, let message):
return "Decodex App helper exited with status \(code): \(message)"
return "Decodex App bridge command failed with status \(code): \(message)"
case .invalidResponse(let message):
return "Invalid Decodex App helper response: \(message)"
return "Invalid Decodex App bridge response: \(message)"
}
}
}
Expand All @@ -26,46 +26,60 @@ struct AppBridgeRequest: Encodable, Sendable {
let authJsonPath: String?
let codexBin: String?
let keepTempHome: Bool?
let includeUsage: Bool?
let forceRefresh: Bool?

enum CodingKeys: String, CodingKey {
case operation
case selector
case authJsonPath = "auth_json_path"
case codexBin = "codex_bin"
case keepTempHome = "keep_temp_home"
case includeUsage = "include_usage"
}

static let accountList = AppBridgeRequest(operation: "account_list")
static let accountClear = AppBridgeRequest(operation: "account_clear")
static func accountList(forceRefresh: Bool = false) -> AppBridgeRequest {
AppBridgeRequest(
operation: "account_list",
includeUsage: true,
forceRefresh: forceRefresh
)
}

static let accountClear = AppBridgeRequest(operation: "account_clear", includeUsage: true)

static func accountUse(selector: String) -> AppBridgeRequest {
AppBridgeRequest(operation: "account_use", selector: selector)
}

static func accountSelect(selector: String) -> AppBridgeRequest {
AppBridgeRequest(operation: "account_select", selector: selector)
AppBridgeRequest(operation: "account_select", selector: selector, includeUsage: true)
}

static func accountLogout(selector: String) -> AppBridgeRequest {
AppBridgeRequest(operation: "account_logout", selector: selector)
AppBridgeRequest(operation: "account_logout", selector: selector, includeUsage: true)
}

static func accountLogin() -> AppBridgeRequest {
AppBridgeRequest(operation: "account_login")
AppBridgeRequest(operation: "account_login", includeUsage: true)
}

private init(
operation: String,
selector: String? = nil,
authJsonPath: String? = nil,
codexBin: String? = nil,
keepTempHome: Bool? = nil
keepTempHome: Bool? = nil,
includeUsage: Bool? = nil,
forceRefresh: Bool? = nil
) {
self.operation = operation
self.selector = selector
self.authJsonPath = authJsonPath
self.codexBin = codexBin
self.keepTempHome = keepTempHome
self.includeUsage = includeUsage
self.forceRefresh = forceRefresh
}
}

Expand Down Expand Up @@ -175,6 +189,15 @@ struct DecodexAppBridge: Sendable {
as type: T.Type,
onOutput: (@MainActor @Sendable (String) -> Void)?
) async throws -> T {
if onOutput == nil, try request.serverRoute() != nil {
return try await DecodexServerBridge.shared.run(request, as: type)
}
guard request.operation == "account_login" else {
throw DecodexAppBridgeError.invalidResponse(
"operation \(request.operation) must be served by Decodex server"
)
}

let helperURL = try helperExecutableURL()
let requestData = try JSONEncoder().encode(request)

Expand Down
Loading