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
This commit is contained in:
Rashid Razak 2026-05-11 19:06:08 +08:00
parent d9acd8c4cd
commit f5b0ac500f
7 changed files with 226 additions and 23 deletions

View file

@ -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"],

View file

@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>

View file

@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
@ -150,11 +153,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
<string>com.codeburn.refresh</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/osascript</string>
<string>-l</string>
<string>JavaScript</string>
<string>-e</string>
<string>ObjC.import("Foundation"); $.NSDistributedNotificationCenter.defaultCenter.postNotificationNameObjectUserInfoDeliverImmediately("com.codeburn.refresh", $(), $(), true)</string>
<string>\(agentPath)</string>
</array>
<key>StartInterval</key>
<integer>30</integer>
@ -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<sockaddr_un>.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

View file

@ -0,0 +1,8 @@
import Foundation
DistributedNotificationCenter.default().postNotificationName(
.init("com.codeburn.refresh"),
object: nil,
userInfo: nil,
options: .deliverImmediately
)

View file

@ -0,0 +1,137 @@
import Testing
import Foundation
import ServiceManagement
private func makePlist(agentPath: String) -> String {
"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.codeburn.refresh</string>
<key>ProgramArguments</key>
<array>
<string>\(agentPath)</string>
</array>
<key>StartInterval</key>
<integer>30</integer>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
"""
}
@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")
}

View file

@ -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
}

12
src/menubar-socket.ts Normal file
View file

@ -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()
}