mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
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:
parent
d9acd8c4cd
commit
f5b0ac500f
7 changed files with 226 additions and 23 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"?>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
mac/Sources/CodeBurnRefreshAgent/main.swift
Normal file
8
mac/Sources/CodeBurnRefreshAgent/main.swift
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import Foundation
|
||||
|
||||
DistributedNotificationCenter.default().postNotificationName(
|
||||
.init("com.codeburn.refresh"),
|
||||
object: nil,
|
||||
userInfo: nil,
|
||||
options: .deliverImmediately
|
||||
)
|
||||
137
mac/Tests/CodeBurnMenubarTests/EDRDetectionFixTests.swift
Normal file
137
mac/Tests/CodeBurnMenubarTests/EDRDetectionFixTests.swift
Normal 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")
|
||||
}
|
||||
|
|
@ -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
12
src/menubar-socket.ts
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue