Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

265 lines
10 KiB
Swift

// activity-collector.swift
//
// Long-running macOS helper that subscribes to NSWorkspace application-focus
// notifications and emits one JSON object per line to stdout.
//
// Build (Darwin only):
// swiftc -O activity-collector.swift -o activity-collector
//
// Output format (one JSON object per line, trailing \n):
// {"ts":1714000000000,"event":"activate","bundleId":"com.apple.Safari","appName":"Safari","windowTitle":"Example — Google Search"}
// {"ts":1714000003000,"event":"deactivate","bundleId":"com.apple.Safari","appName":"Safari"}
//
// Contract:
// - Writes complete lines terminated with \n.
// - Flushes stdout after every line.
// - Exits cleanly on SIGTERM / SIGINT.
// - No stderr output unless a fatal error occurs (stderr line prefixed "[activity-collector] ").
//
// System sleep / lock integration:
// - On system sleep, screen lock, or screens-off, we emit a synthetic
// `deactivate` for the last-activated app so downstream sleep inference
// does not treat a lingering frontmost app as hours of continuous use.
// - On wake / unlock, we re-emit an `activate` for the current frontmost app.
//
// The TypeScript service spawns this helper, pipes stdout, and persists events.
#if os(macOS)
import Foundation
import AppKit
import CoreGraphics
// Avoid Swift's JSONEncoder overhead per-event: build the JSON string manually.
// Escape the minimum set required by RFC 8259 for string scalars.
func jsonEscape(_ s: String) -> String {
var out = ""
out.reserveCapacity(s.count + 2)
for scalar in s.unicodeScalars {
switch scalar {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
case "\u{08}": out += "\\b"
case "\u{0C}": out += "\\f"
default:
if scalar.value < 0x20 {
out += String(format: "\\u%04x", scalar.value)
} else {
out.unicodeScalars.append(scalar)
}
}
}
return out
}
func emit(event: String, bundleId: String, appName: String, windowTitle: String?) {
let tsMs = Int64(Date().timeIntervalSince1970 * 1000)
var fields = [
"\"ts\":\(tsMs)",
"\"event\":\"\(jsonEscape(event))\"",
"\"bundleId\":\"\(jsonEscape(bundleId))\"",
"\"appName\":\"\(jsonEscape(appName))\"",
]
if let title = windowTitle, !title.isEmpty {
fields.append("\"windowTitle\":\"\(jsonEscape(title))\"")
}
let line = "{" + fields.joined(separator: ",") + "}\n"
FileHandle.standardOutput.write(line.data(using: .utf8) ?? Data())
}
// Emit a periodic HID idle sample so LifeOps can infer passive-media vs
// away-from-keyboard without depending on the Electrobun desktop bridge.
// CGEventSourceSecondsSinceLastEventType is session-bound; it reports the
// idle time for the active console session which is exactly what we want
// for the signed-in owner.
func emitHidIdle(_ idleSeconds: Double) {
let tsMs = Int64(Date().timeIntervalSince1970 * 1000)
let rounded = max(0, Int(idleSeconds.rounded()))
let line = "{\"ts\":\(tsMs),\"event\":\"hid_idle\",\"idleSeconds\":\(rounded)}\n"
FileHandle.standardOutput.write(line.data(using: .utf8) ?? Data())
}
func currentHidIdleSeconds() -> Double {
// `anyInputEventType` covers mouse + keyboard; using combinedSessionState
// so we read idle for the active session, not the process.
let anyInputEventType = CGEventType(rawValue: UInt32.max)!
return CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInputEventType)
}
func frontmostWindowTitle(for app: NSRunningApplication) -> String? {
// Reading the window title requires Accessibility permission. We attempt
// it via AX API; any failure returns nil (no windowTitle field emitted).
let pid = app.processIdentifier
let axApp = AXUIElementCreateApplication(pid)
var focused: AnyObject?
let err = AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &focused)
guard err == .success, let window = focused else { return nil }
var titleValue: AnyObject?
let titleErr = AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleValue)
guard titleErr == .success, let titleStr = titleValue as? String else { return nil }
return titleStr
}
final class Collector {
let workspace = NSWorkspace.shared
var lastActivatedBundleId: String?
var lastActivatedAppName: String?
func start() {
let nc = workspace.notificationCenter
nc.addObserver(
forName: NSWorkspace.didActivateApplicationNotification,
object: nil,
queue: .main
) { [weak self] note in
self?.handleActivate(note)
}
nc.addObserver(
forName: NSWorkspace.didDeactivateApplicationNotification,
object: nil,
queue: .main
) { [weak self] note in
self?.handleDeactivate(note)
}
// macOS does not fire NSWorkspace.didDeactivateApplicationNotification
// when the system sleeps, the screen locks, or the screensaver kicks
// in. Without a synthetic deactivate the last-activated app looks
// "focused" for hours, which hides sleep from downstream inference.
let systemDeactivateNames: [Notification.Name] = [
NSWorkspace.willSleepNotification,
NSWorkspace.screensDidSleepNotification,
NSWorkspace.sessionDidResignActiveNotification,
]
for name in systemDeactivateNames {
nc.addObserver(
forName: name,
object: nil,
queue: .main
) { [weak self] _ in
self?.emitSystemDeactivate(reason: name.rawValue)
}
}
let systemActivateNames: [Notification.Name] = [
NSWorkspace.didWakeNotification,
NSWorkspace.screensDidWakeNotification,
NSWorkspace.sessionDidBecomeActiveNotification,
]
for name in systemActivateNames {
nc.addObserver(
forName: name,
object: nil,
queue: .main
) { [weak self] _ in
self?.emitSystemActivate()
}
}
// The screen-locked / screen-unlocked notifications live on the
// Distributed Notification Center, not the workspace center.
let dnc = DistributedNotificationCenter.default()
dnc.addObserver(
forName: Notification.Name("com.apple.screenIsLocked"),
object: nil,
queue: .main
) { [weak self] _ in
self?.emitSystemDeactivate(reason: "screenIsLocked")
}
dnc.addObserver(
forName: Notification.Name("com.apple.screenIsUnlocked"),
object: nil,
queue: .main
) { [weak self] _ in
self?.emitSystemActivate()
}
// Emit the current frontmost app as a synthetic first activate so the
// consumer has a starting anchor for duration computation.
if let current = workspace.frontmostApplication {
let bundleId = current.bundleIdentifier ?? ""
let appName = current.localizedName ?? ""
let title = frontmostWindowTitle(for: current)
lastActivatedBundleId = bundleId
lastActivatedAppName = appName
emit(event: "activate", bundleId: bundleId, appName: appName, windowTitle: title)
}
}
func handleActivate(_ note: Notification) {
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else {
return
}
let bundleId = app.bundleIdentifier ?? ""
let appName = app.localizedName ?? ""
let title = frontmostWindowTitle(for: app)
lastActivatedBundleId = bundleId
lastActivatedAppName = appName
emit(event: "activate", bundleId: bundleId, appName: appName, windowTitle: title)
}
func handleDeactivate(_ note: Notification) {
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else {
return
}
let bundleId = app.bundleIdentifier ?? ""
let appName = app.localizedName ?? ""
emit(event: "deactivate", bundleId: bundleId, appName: appName, windowTitle: nil)
}
func emitSystemDeactivate(reason: String) {
guard let bundleId = lastActivatedBundleId else { return }
let appName = lastActivatedAppName ?? ""
emit(event: "deactivate", bundleId: bundleId, appName: appName, windowTitle: nil)
// Clear so a subsequent deactivate does not double-emit for the same
// system-sleep transition.
lastActivatedBundleId = nil
lastActivatedAppName = nil
_ = reason
}
func emitSystemActivate() {
guard let current = workspace.frontmostApplication else { return }
let bundleId = current.bundleIdentifier ?? ""
let appName = current.localizedName ?? ""
let title = frontmostWindowTitle(for: current)
lastActivatedBundleId = bundleId
lastActivatedAppName = appName
emit(event: "activate", bundleId: bundleId, appName: appName, windowTitle: title)
}
}
// Line-buffer stdout so the consumer sees events immediately.
setbuf(stdout, nil)
let signalSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
signalSource.setEventHandler { exit(0) }
signalSource.resume()
signal(SIGTERM, SIG_IGN)
let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
intSource.setEventHandler { exit(0) }
intSource.resume()
signal(SIGINT, SIG_IGN)
let collector = Collector()
collector.start()
// Periodic HID idle sampling. 30s cadence is cheap and gives the LifeOps
// scorer enough resolution to distinguish passive-media from away-from-desk
// (20 min idle is the awake_state timeout in `sleep-wake-spec.md`).
let hidIdleTimer = DispatchSource.makeTimerSource(queue: .main)
hidIdleTimer.schedule(deadline: .now() + .seconds(5), repeating: .seconds(30))
hidIdleTimer.setEventHandler {
emitHidIdle(currentHidIdleSeconds())
}
hidIdleTimer.resume()
RunLoop.main.run()
#else
// Non-Darwin unsupported entrypoint. The collector only runs on macOS, but this
// branch keeps the file compileable on Linux CI.
import Foundation
FileHandle.standardError.write("[activity-collector] This helper only runs on macOS.\n".data(using: .utf8) ?? Data())
exit(2)
#endif