Skip to content
Draft
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
24 changes: 24 additions & 0 deletions mac/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,30 @@ codeburn menubar

That's it. The command records the persistent `codeburn` CLI path, downloads the latest `.app` from the newest `mac-v*` GitHub Release with a matching checksum, verifies it, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise.

If the process runs but its status item never appears, including after a
reinstall and reboot, macOS may have retained bad per-bundle-id placement state.
First refresh the installed app so it can safely transfer its own Login Item
state, then repair it without keeping a duplicate app:

```bash
codeburn menubar --force
codeburn menubar --repair-placement
```

The installer verifies the official release first, replaces the existing app
at the same path with a locally re-signed copy using a fresh CodeBurn recovery
bundle id, and preserves that id across future `--force` updates. macOS may ask
for CodeBurn permissions again because the repaired app has a new local code
identity. A Developer-ID signed or notarized app cannot be re-identified
locally without invalidating its signature, so the command fails safely for
those artifacts instead of silently downgrading them.

To return to the official bundle identity later, reinstall it explicitly:

```bash
codeburn menubar --reset-placement
```

### Build from source

For contributors running a local build instead of the packaged release:
Expand Down
2 changes: 2 additions & 0 deletions mac/Scripts/package-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_ID}</string>
<key>CodeBurnLoginItemMaintenanceVersion</key>
<integer>1</integer>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
Expand Down
80 changes: 76 additions & 4 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI
import AppKit
import Observation
import ServiceManagement
import Darwin

private let refreshIntervalSeconds: UInt64 = 30
private let forceRefreshWatchdogSeconds: TimeInterval = 90
Expand Down Expand Up @@ -122,6 +123,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
}

func applicationDidFinishLaunching(_ notification: Notification) {
runMaintenanceCommandIfRequested()
ProcessInfo.processInfo.automaticTerminationSupportEnabled = false
ProcessInfo.processInfo.disableSuddenTermination()
// Deliberately NO app-lifetime beginActivity here. A permanent
Expand Down Expand Up @@ -155,6 +157,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
Task { await updateChecker.checkIfNeeded() }
}

/// Runs only from the installed source bundle during identity repair. The
/// exact installed code identity is required for SMAppService to address
/// the source registration. Packaged builds advertise this protocol in
/// Info.plist so older binaries are never launched with an unknown flag.
private func runMaintenanceCommandIfRequested() {
let arguments = ProcessInfo.processInfo.arguments
let statusRequested = arguments.contains("--codeburn-login-item-status")
let unregisterRequested = arguments.contains("--codeburn-unregister-login-item")
let registerRequested = arguments.contains("--codeburn-register-login-item")
guard statusRequested || unregisterRequested || registerRequested else {
return
}

let key = "codeburn.loginItemRegistered"
let service = SMAppService.mainApp
do {
let state = LoginItemRegistrationPolicy.migrationState(
status: service.status,
wasPreviouslyRegistered: UserDefaults.standard.bool(forKey: key)
)
if unregisterRequested {
switch service.status {
case .enabled, .requiresApproval:
try service.unregister()
case .notRegistered, .notFound:
break
@unknown default:
break
}
} else if registerRequested, service.status != .enabled {
try service.register()
}
let resultState: LoginItemMigrationState
if registerRequested {
switch service.status {
case .enabled:
resultState = .registered
case .requiresApproval:
resultState = .disabled
case .notRegistered, .notFound:
resultState = .notRegistered
@unknown default:
resultState = .unknown
}
} else {
// Unregister reports the state that was retired so the caller
// can verify it addressed the intended identity.
resultState = state
}
print(resultState.rawValue)
fflush(stdout)
Darwin.exit(EXIT_SUCCESS)
} catch {
let message = "CodeBurn Login Item maintenance failed: \(error.localizedDescription)\n"
FileHandle.standardError.write(Data(message.utf8))
Darwin.exit(EXIT_FAILURE)
}
}

private func setupWakeObservers() {
// Pause the refresh loop while the machine is asleep. Without this,
// Task.sleep keeps a wakeup pending across the suspension and the
Expand Down Expand Up @@ -285,15 +346,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM

private func registerLoginItemIfNeeded() {
let key = "codeburn.loginItemRegistered"
guard !UserDefaults.standard.bool(forKey: key) else { return }
let service = SMAppService.mainApp
let wasPreviouslyRegistered = UserDefaults.standard.bool(forKey: key)
guard LoginItemRegistrationPolicy.shouldRegister(
status: service.status,
wasPreviouslyRegistered: wasPreviouslyRegistered
) else {
if LoginItemRegistrationPolicy.shouldRecordRegistration(
status: service.status,
wasPreviouslyRegistered: wasPreviouslyRegistered
) {
UserDefaults.standard.set(true, forKey: key)
}
return
}

// Registers in-process. The old path told System Events to make the login
// item, which made macOS ask for Automation access on first launch (#1026).
// No AppleScript fallback: a failure here must not bring that prompt back.
do {
if SMAppService.mainApp.status != .enabled {
try SMAppService.mainApp.register()
}
try service.register()
UserDefaults.standard.set(true, forKey: key)
} catch {
NSLog("CodeBurn: login item registration failed: \(error.localizedDescription)")
Expand Down
56 changes: 56 additions & 0 deletions mac/Sources/CodeBurnMenubar/LoginItemRegistrationPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import ServiceManagement

enum LoginItemMigrationState: String, Equatable {
case registered
case disabled
case notRegistered = "not-registered"
case unknown
}

enum LoginItemRegistrationPolicy {
static func shouldRegister(
status: SMAppService.Status,
wasPreviouslyRegistered: Bool
) -> Bool {
switch status {
case .enabled, .requiresApproval:
return false
case .notRegistered, .notFound:
// Once registration has succeeded, .notRegistered represents the
// user's later choice in System Settings. Never fight that choice.
return !wasPreviouslyRegistered
@unknown default:
return false
}
}

static func migrationState(
status: SMAppService.Status,
wasPreviouslyRegistered: Bool
) -> LoginItemMigrationState {
switch status {
case .enabled:
return .registered
case .requiresApproval:
// Apple uses requiresApproval both while first approval is pending
// and after previously-granted consent is revoked. Released builds
// wrote the registration marker before approval, so the legacy
// marker plus no enabled observation is irreducibly ambiguous.
// Fail closed: never re-register a replacement identity if that
// could override the user's explicit System Settings choice.
return .disabled
case .notRegistered, .notFound:
return wasPreviouslyRegistered ? .disabled : .notRegistered
@unknown default:
return .unknown
}
}

static func shouldRecordRegistration(
status: SMAppService.Status,
wasPreviouslyRegistered: Bool
) -> Bool {
status == .enabled || wasPreviouslyRegistered
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import ServiceManagement
import Testing
@testable import CodeBurnMenubar

@Suite("Login item registration policy")
struct LoginItemRegistrationPolicyTests {
@Test("registers once without overriding a later user disable")
func respectsStatusAndRegistrationHistory() {
#expect(!LoginItemRegistrationPolicy.shouldRegister(status: .enabled, wasPreviouslyRegistered: false))
#expect(!LoginItemRegistrationPolicy.shouldRegister(status: .requiresApproval, wasPreviouslyRegistered: false))
#expect(LoginItemRegistrationPolicy.shouldRegister(status: .notRegistered, wasPreviouslyRegistered: false))
#expect(LoginItemRegistrationPolicy.shouldRegister(status: .notFound, wasPreviouslyRegistered: false))
#expect(!LoginItemRegistrationPolicy.shouldRegister(status: .notRegistered, wasPreviouslyRegistered: true))
#expect(!LoginItemRegistrationPolicy.shouldRegister(status: .notFound, wasPreviouslyRegistered: true))
}

@Test("classifies revoked approval as disabled during identity migration")
func migrationStatePreservesConsent() {
#expect(LoginItemRegistrationPolicy.migrationState(
status: .enabled,
wasPreviouslyRegistered: true
) == .registered)
#expect(LoginItemRegistrationPolicy.migrationState(
status: .requiresApproval,
wasPreviouslyRegistered: true
) == .disabled)
#expect(LoginItemRegistrationPolicy.migrationState(
status: .requiresApproval,
wasPreviouslyRegistered: false
) == .disabled)
#expect(LoginItemRegistrationPolicy.migrationState(
status: .notRegistered,
wasPreviouslyRegistered: true
) == .disabled)
#expect(LoginItemRegistrationPolicy.migrationState(
status: .notRegistered,
wasPreviouslyRegistered: false
) == .notRegistered)
#expect(LoginItemRegistrationPolicy.shouldRecordRegistration(
status: .enabled,
wasPreviouslyRegistered: false
))
#expect(!LoginItemRegistrationPolicy.shouldRecordRegistration(
status: .requiresApproval,
wasPreviouslyRegistered: false
))
#expect(LoginItemRegistrationPolicy.shouldRecordRegistration(
status: .requiresApproval,
wasPreviouslyRegistered: true
))
}
}
17 changes: 14 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1392,11 +1392,22 @@ program
.command('menubar')
.description('Install and launch the menubar app on macOS and Windows (one command, no clone)')
.option('--force', 'Reinstall even if a copy is already installed')
.action(async (opts: { force?: boolean }) => {
.option('--repair-placement', 'Repair a missing macOS menu bar item with a fresh local bundle identity')
.option('--reset-placement', 'Return the macOS menu bar app to its official bundle identity')
.action(async (opts: { force?: boolean; repairPlacement?: boolean; resetPlacement?: boolean }) => {
try {
const result = await installMenubarApp({ force: opts.force, cliVersion: version })
const result = await installMenubarApp({
force: opts.force,
repairPlacement: opts.repairPlacement,
resetPlacement: opts.resetPlacement,
cliVersion: version,
})
// A cancelled Windows installer leaves nothing to point at.
if (result.installedPath) console.log(`\n Ready. ${result.installedPath}\n`)
if (result.installedPath) {
console.log(result.launched
? `\n Ready. ${result.installedPath}\n`
: `\n Installed. Open ${result.installedPath} manually.\n`)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Menubar install failed: ${message}\n`)
Expand Down
Loading
Loading