From f5b0ac500f92ce1efdbba29232c99829c8656dbf Mon Sep 17 00:00:00 2001 From: Rashid Razak Date: Mon, 11 May 2026 19:06:08 +0800 Subject: [PATCH] Replace osascript/JXA with native Mach-O agent and SMAppService to fix EDR detection - Add CodeBurnRefreshAgent target: native fire-and-exit binary posting com.codeburn.refresh notification - Rewrite installLaunchAgentIfNeeded(): plist ProgramArguments points to native binary, not osascript/JXA - Rewrite registerLoginItemIfNeeded(): uses SMAppService API instead of osascript/System Events - Add startSocketListener(): Unix domain socket for CLI-triggered menubar refresh - Add src/menubar-socket.ts: CLI-side notifyMenubar() helper wired into status --format menubar-json - Update Package.swift with new product/target, package-app.sh copies agent into bundle Resources - Add tests: plist content verification, login item guard, agent smoke test --- mac/Package.swift | 7 +- mac/Scripts/package-app.sh | 1 + mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 82 ++++++++--- mac/Sources/CodeBurnRefreshAgent/main.swift | 8 + .../EDRDetectionFixTests.swift | 137 ++++++++++++++++++ src/cli.ts | 2 + src/menubar-socket.ts | 12 ++ 7 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 mac/Sources/CodeBurnRefreshAgent/main.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/EDRDetectionFixTests.swift create mode 100644 src/menubar-socket.ts diff --git a/mac/Package.swift b/mac/Package.swift index 67509f2..ff8161e 100644 --- a/mac/Package.swift +++ b/mac/Package.swift @@ -7,7 +7,8 @@ let package = Package( .macOS(.v14) ], products: [ - .executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"]) + .executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"]), + .executable(name: "CodeBurnRefreshAgent", targets: ["CodeBurnRefreshAgent"]) ], targets: [ .executableTarget( @@ -17,6 +18,10 @@ let package = Package( .enableUpcomingFeature("StrictConcurrency") ] ), + .executableTarget( + name: "CodeBurnRefreshAgent", + path: "Sources/CodeBurnRefreshAgent" + ), .testTarget( name: "CodeBurnMenubarTests", dependencies: ["CodeBurnMenubar"], diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index 5de94ed..d5c9c58 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -43,6 +43,7 @@ BUNDLE="${DIST_DIR}/${BUNDLE_NAME}" mkdir -p "${BUNDLE}/Contents/MacOS" mkdir -p "${BUNDLE}/Contents/Resources" cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}" +cp "${BIN_PATH}/CodeBurnRefreshAgent" "${BUNDLE}/Contents/Resources/CodeBurnRefreshAgent" cat > "${BUNDLE}/Contents/Info.plist" < diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 5868258..e273905 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import Observation +import ServiceManagement private let refreshIntervalSeconds: UInt64 = 30 private let nanosPerSecond: UInt64 = 1_000_000_000 @@ -76,6 +77,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { setupDistributedNotificationListener() installLaunchAgentIfNeeded() registerLoginItemIfNeeded() + startSocketListener() observeSubscriptionDisconnect() Task { await updateChecker.checkIfNeeded() } } @@ -140,6 +142,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let agentName = "com.codeburn.refresh.plist" let home = fm.homeDirectoryForCurrentUser.path let destPath = "\(home)/Library/LaunchAgents/\(agentName)" + let agentPath = (Bundle.main.resourcePath ?? "") + "/CodeBurnRefreshAgent" let plist = """ @@ -150,11 +153,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { com.codeburn.refresh ProgramArguments - /usr/bin/osascript - -l - JavaScript - -e - ObjC.import("Foundation"); $.NSDistributedNotificationCenter.defaultCenter.postNotificationNameObjectUserInfoDeliverImmediately("com.codeburn.refresh", $(), $(), true) + \(agentPath) StartInterval 30 @@ -188,29 +187,68 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } private func registerLoginItemIfNeeded() { - let key = "codeburn.loginItemRegistered" - guard !UserDefaults.standard.bool(forKey: key) else { return } - - let appPath = Bundle.main.bundlePath - let script = "tell application \"System Events\" to make login item at end with properties {path:\"\(appPath)\", hidden:false}" - - let process = Process() - process.launchPath = "/usr/bin/osascript" - process.arguments = ["-e", script] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - + guard SMAppService.mainApp.status != .enabled else { return } do { - try process.run() - process.waitUntilExit() - if process.terminationStatus == 0 { - UserDefaults.standard.set(true, forKey: key) - } + try SMAppService.mainApp.register() } catch { NSLog("CodeBurn: Login item registration failed: \(error)") } } + private func startSocketListener() { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + let cacheDir = "\(home)/.cache/codeburn" + let socketPath = "\(cacheDir)/menubar.sock" + + try? fm.createDirectory(atPath: cacheDir, withIntermediateDirectories: true) + if fm.fileExists(atPath: socketPath) { + try? fm.removeItem(atPath: socketPath) + } + + let socketFD = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { + NSLog("CodeBurn: failed to create socket") + return + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let socketPathC = (socketPath as NSString).fileSystemRepresentation + withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in + _ = strcpy(ptr, socketPathC) + } + + let addrSize = socklen_t(MemoryLayout.size) + let bindResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(socketFD, $0, addrSize) + } + } + guard bindResult == 0 else { + NSLog("CodeBurn: failed to bind socket at \(socketPath)") + Darwin.close(socketFD) + return + } + + Darwin.listen(socketFD, 5) + + DispatchQueue.global(qos: .background).async { [weak self, socketFD] in + while true { + let clientFD = Darwin.accept(socketFD, nil, nil) + guard clientFD >= 0 else { continue } + var buf: [UInt8] = Array(repeating: 0, count: 1024) + let n = Darwin.read(clientFD, &buf, buf.count) + if n > 0 { + DispatchQueue.main.async { [weak self] in + self?.forceRefresh() + } + } + Darwin.close(clientFD) + } + } + } + private var lastRefreshTime: Date = .distantPast @discardableResult diff --git a/mac/Sources/CodeBurnRefreshAgent/main.swift b/mac/Sources/CodeBurnRefreshAgent/main.swift new file mode 100644 index 0000000..56b8ad2 --- /dev/null +++ b/mac/Sources/CodeBurnRefreshAgent/main.swift @@ -0,0 +1,8 @@ +import Foundation + +DistributedNotificationCenter.default().postNotificationName( + .init("com.codeburn.refresh"), + object: nil, + userInfo: nil, + options: .deliverImmediately +) diff --git a/mac/Tests/CodeBurnMenubarTests/EDRDetectionFixTests.swift b/mac/Tests/CodeBurnMenubarTests/EDRDetectionFixTests.swift new file mode 100644 index 0000000..82dc757 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/EDRDetectionFixTests.swift @@ -0,0 +1,137 @@ +import Testing +import Foundation +import ServiceManagement + +private func makePlist(agentPath: String) -> String { + """ + + + + + Label + com.codeburn.refresh + ProgramArguments + + \(agentPath) + + StartInterval + 30 + RunAtLoad + + + +""" +} + +@Suite("LaunchAgent Plist") +struct LaunchAgentPlistTests { + @Test("Plist has correct ProgramArguments") + func programArgumentsIsSingleElementArray() throws { + let plistStr = makePlist(agentPath: "/path/to/CodeBurnRefreshAgent") + let data = Data(plistStr.utf8) + let raw = try PropertyListSerialization.propertyList(from: data, format: nil) + let dict = try #require(raw as? NSDictionary) + let args = try #require(dict["ProgramArguments"] as? [String]) + #expect(args == ["/path/to/CodeBurnRefreshAgent"]) + } + + @Test("Plist has StartInterval of 30") + func startIntervalIs30() throws { + let plistStr = makePlist(agentPath: "/path/to/agent") + let data = Data(plistStr.utf8) + let raw = try PropertyListSerialization.propertyList(from: data, format: nil) + let dict = try #require(raw as? NSDictionary) + let interval = try #require(dict["StartInterval"] as? Int) + #expect(interval == 30) + } + + @Test("Plist has RunAtLoad true") + func runAtLoadIsTrue() throws { + let plistStr = makePlist(agentPath: "/path/to/agent") + let data = Data(plistStr.utf8) + let raw = try PropertyListSerialization.propertyList(from: data, format: nil) + let dict = try #require(raw as? NSDictionary) + let runAtLoad = try #require(dict["RunAtLoad"] as? Bool) + #expect(runAtLoad == true) + } + + @Test("Plist has correct Label") + func labelIsCorrect() throws { + let plistStr = makePlist(agentPath: "/path/to/agent") + let data = Data(plistStr.utf8) + let raw = try PropertyListSerialization.propertyList(from: data, format: nil) + let dict = try #require(raw as? NSDictionary) + let label = try #require(dict["Label"] as? String) + #expect(label == "com.codeburn.refresh") + } + + @Test("Plist idempotency") + func idempotent() { + let a = makePlist(agentPath: "/same/path") + let b = makePlist(agentPath: "/same/path") + #expect(a == b) + } +} + +@Suite("Login Item Guard") +struct LoginItemGuardTests { + @Test("SMAppService.mainApp.status is accessible") + func mainAppStatusIsAccessible() { + // The guard in registerLoginItemIfNeeded(): + // guard SMAppService.mainApp.status != .enabled else { return } + // When status is .enabled, the function returns early (no registration). + // When status is .notRegistered / .requiresApproval, it proceeds to register. + let status = SMAppService.mainApp.status + // In a running app, status is .enabled, .notRegistered, or .requiresApproval. + // In a test context without an app bundle, it may be .notFound (macOS 14+). + let known: Bool = status == .enabled || status == .notRegistered + || status == .requiresApproval || status == .notFound + #expect(known) + } +} + +@Test("CodeBurnRefreshAgent builds and runs successfully") +func agentBuildsAndRuns() throws { + let packageDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let scratchDir = FileManager.default.temporaryDirectory + .appendingPathComponent("codeburn-smoke-test-build") + try? FileManager.default.removeItem(at: scratchDir) + + let build = Process() + build.launchPath = "/usr/bin/env" + build.arguments = [ + "swift", "build", "--product", "CodeBurnRefreshAgent", + "--scratch-path", scratchDir.path + ] + build.currentDirectoryURL = packageDir + try build.run() + build.waitUntilExit() + #expect(build.terminationStatus == 0, "Build failed") + + let showPath = Process() + let pipe = Pipe() + showPath.launchPath = "/usr/bin/env" + showPath.arguments = [ + "swift", "build", "--product", "CodeBurnRefreshAgent", + "--scratch-path", scratchDir.path, "--show-bin-path" + ] + showPath.currentDirectoryURL = packageDir + showPath.standardOutput = pipe + try showPath.run() + showPath.waitUntilExit() + + let binPathData = pipe.fileHandleForReading.readDataToEndOfFile() + let binPath = String(data: binPathData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let binaryURL = URL(fileURLWithPath: binPath).appendingPathComponent("CodeBurnRefreshAgent") + + let agent = Process() + agent.launchPath = binaryURL.path + try agent.run() + agent.waitUntilExit() + #expect(agent.terminationStatus == 0, "Agent exited with non-zero status") +} diff --git a/src/cli.ts b/src/cli.ts index 4ebfe33..abc2b0b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import { Command } from 'commander' import { installMenubarApp } from './menubar-installer.js' +import { notifyMenubar } from './menubar-socket.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' import { loadPricing, setModelAliases } from './models.js' import { parseAllSessions, filterProjectsByName } from './parser.js' @@ -515,6 +516,7 @@ program const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory))) + notifyMenubar() return } diff --git a/src/menubar-socket.ts b/src/menubar-socket.ts new file mode 100644 index 0000000..fd874d5 --- /dev/null +++ b/src/menubar-socket.ts @@ -0,0 +1,12 @@ +import { connect } from 'node:net' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const SOCKET_PATH = join(homedir(), '.cache', 'codeburn', 'menubar.sock') + +export function notifyMenubar(): void { + const sock = connect(SOCKET_PATH) + sock.on('error', () => {}) + sock.write('refresh\n') + sock.end() +}