Skip to content
Open
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
563 changes: 544 additions & 19 deletions app/electron/cli.test.ts

Large diffs are not rendered by default.

318 changes: 250 additions & 68 deletions app/electron/cli.ts

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, shell, type MenuItemConstructorOptions } from 'electron'
import path from 'node:path'

import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, startServeWarmup, type ActionResult, type SpawnPriority } from './cli'
import { CliError, DESKTOP_COLD_TIMEOUT_MS, resolveCodeburnPath, shutdownAll, spawnCli, spawnCliAction, startServe, type ActionResult, type SpawnPriority } from './cli'
import { getQuota, sanitizeError } from './quota'
import { Telemetry } from './telemetry'
import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates'
Expand Down Expand Up @@ -77,7 +77,7 @@ export type Envelope<T = unknown> = { ok: true; value: T } | { ok: false; error:
// slowness. Give the first (cold) overview a long window; revert to the default
// once it succeeds. Sections gate their own first poll on this one resolving so
// the cold hydration runs ONCE, not once per section in parallel.
const WARMUP_TIMEOUT_MS = 10 * 60_000
const WARMUP_TIMEOUT_MS = DESKTOP_COLD_TIMEOUT_MS
// Wire marker for CLI scan-progress lines (src/parser.ts: PROGRESS_LINE_PREFIX).
const PROGRESS_LINE_PREFIX = 'CODEBURN_PROGRESS '
// IPC channel carrying cold-start scan-progress events to the splash.
Expand Down Expand Up @@ -564,15 +564,15 @@ function bootstrap(): void {

app.on('before-quit', createBeforeQuitHandler({
getTelemetry: () => telemetryInstance,
killAll,
killAll: shutdownAll,
quit: () => app.quit(),
}))

void app.whenReady().then(() => {
// Start the resident serve child early so its warm-up (one cache parse)
// finishes during the first panels' cold spawns; every fetch after that
// answers from the warm child in milliseconds.
startServeWarmup()
// Start the resident child early, but issue no artificial warm-up query:
// the first real overview request is the single cache hydration and streams
// its progress through serve. Every later panel reuses that parsed cache.
startServe()
// Consent-gated anonymous telemetry (desktop only). Nothing transmits until
// the onboarding consent screen is completed and the toggle is on; EU/EEA/
// UK/CH installs default the toggle off. Dev builds never send.
Expand Down
5 changes: 2 additions & 3 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
// interaction (popover open, wake) refreshes immediately.

restorePersistedCurrency()
// Resident serve child: payload fetches answer from a warm CLI once
// its warm-up completes; until then (and on any failure) fetches keep
// the spawn path. See ServeConnection.
// Start the resident CLI early without an artificial query. The first
// real status refresh becomes its only cold warm-up. See ServeConnection.
Task { await ServeConnection.shared.ensureStarted() }
// #868 experiment: restore only the activation half of the #147 fix.
// Packaged builds ship LSUIElement=true, so the policy is .accessory
Expand Down
6 changes: 1 addition & 5 deletions mac/Sources/CodeBurnMenubar/CurrencyState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,7 @@ actor FXRateCache {
private var loaded = false

private var cacheFilePath: String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
return base
.appendingPathComponent("codeburn-mac", isDirectory: true)
.appendingPathComponent("fx-rates.json")
.path
return (CodeBurnCacheDirectory.resolve() as NSString).appendingPathComponent("fx-rates.json")
}

private func loadIfNeeded() {
Expand Down
18 changes: 18 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/CodeBurnCacheDirectory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// Resolves the on-disk directory shared by the CLI, desktop app and menubar.
enum CodeBurnCacheDirectory {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
) -> String {
if let override = environment["CODEBURN_CACHE_DIR"],
!override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return override
}
return homeDirectory
.appendingPathComponent(".cache", isDirectory: true)
.appendingPathComponent("codeburn", isDirectory: true)
.path
}
}
69 changes: 58 additions & 11 deletions mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,68 @@ struct DataClient {
subcommand: [String],
qualityOfService: QualityOfService = .userInitiated
) async throws -> ProcessResult {
// Serve fast path: a warm resident `codeburn serve` child answers the
// status payload without a spawn (no node boot, no session-cache
// reload). Any serve failure falls back to the spawn path below, so
// this is strictly an optimization; it also takes no spawn slot.
try await runCLI(
subcommand: subcommand,
serveRequest: { args in
try await ServeConnection.shared.request(args: args)
},
spawnFallback: {
await spawnLimiter.acquire()
defer { Task { await spawnLimiter.release() } }
let process = CodeburnCLI.makeProcess(
subcommand: subcommand,
qualityOfService: qualityOfService
)
return try await runProcess(
process,
timeoutSeconds: spawnTimeoutSeconds,
label: subcommand.joined(separator: " ")
)
}
)
}

/// Internal seam for behavior-shaped lifecycle tests. Production supplies
/// the shared resident and globally limited one-shot closures above.
static func runCLI(
subcommand: [String],
serveRequest: ([String]) async throws -> Data,
spawnFallback: () async throws -> ProcessResult
) async throws -> ProcessResult {
// Serve path: the first real status payload warms the resident child,
// then later payloads reuse it (no node boot or session-cache reload).
// Transport/protocol failures fall back to the spawn path below, so
// the resident remains an optimization. Resource-policy failures stay
// terminal and cannot bypass the resident output ceiling.
if ServeConnection.isEligible(subcommand) {
if let stdout = try? await ServeConnection.shared.requestIfWarm(args: subcommand) {
do {
let stdout = try await serveRequest(subcommand)
return ProcessResult(stdout: stdout, stderr: "", exitCode: 0)
} catch let error as CancellationError {
// Cancellation is control flow from the refresh owner. Starting
// a fallback process here would turn cancelled work into a new
// expensive cold parse and delay task teardown.
throw error
} catch {
if let terminalError = terminalServeError(error) {
throw terminalError
}
// Resident serve is only an optimization. Protocol, child, and
// timeout failures retain the established one-shot fallback,
// unless a sibling teardown raced this task's cancellation.
try Task.checkCancellation()
}
}
await spawnLimiter.acquire()
defer { Task { await spawnLimiter.release() } }
let process = CodeburnCLI.makeProcess(subcommand: subcommand, qualityOfService: qualityOfService)
return try await runProcess(process,
timeoutSeconds: spawnTimeoutSeconds,
label: subcommand.joined(separator: " "))
return try await spawnFallback()
}

/// Some resident failures are terminal resource-policy decisions, not
/// transport failures. Retrying those through the one-shot path would redo
/// the cold scan and could bypass the resident's stricter output ceiling.
static func terminalServeError(_ error: Error) -> DataClientError? {
guard let failure = error as? ServeConnection.ServeRequestFailed,
failure.reason == .outputTooLarge else { return nil }
return .outputTooLarge
}

/// Runs an already-configured process to completion, draining its output and
Expand Down
5 changes: 3 additions & 2 deletions mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ struct MenubarStatusCache {

/// Default location under `~/.cache/codeburn/`.
static func standard() -> MenubarStatusCache {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return MenubarStatusCache(statusPath: "\(home)/.cache/codeburn/menubar-status.json")
let cacheDir = CodeBurnCacheDirectory.resolve()
let path = (cacheDir as NSString).appendingPathComponent("menubar-status.json")
return MenubarStatusCache(statusPath: path)
}

struct BadgeRead {
Expand Down
Loading
Loading