Files
ggbond268--mactools/Plugins/ActivityBar/Sources/ActivityBarController.swift
T
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

401 lines
13 KiB
Swift

import AppKit
import Foundation
import MacToolsPluginKit
@MainActor
final class ActivityBarController: ObservableObject {
static let pluginID = ActivityBarConstants.pluginID
static let defaultSocketPath = ActivityBarConstants.defaultSocketPath
enum HookInstallState: Equatable {
case notInstalled
case installed(timestamp: String)
case failed
}
private enum StorageKey {
static let isTrackingEnabled = "activity-bar.tracking.enabled"
static let hooksInstalledAt = "activity-bar.hooks.installed-at"
}
@Published private(set) var isTrackingEnabled: Bool
@Published private(set) var lastErrorMessage: String?
@Published private(set) var hookInstallState: HookInstallState = .notInstalled
let localization: PluginLocalization
let inputStats: ActivityBarStatsStore
let codingStats: ActivityBarCodingSessionStore
var onStateChange: (() -> Void)?
private let storage: PluginStorage
private let inputMonitor: any ActivityBarInputMonitoring
private let socketServer: any ActivityBarSocketServing
private let inputEventNotificationDelay: Duration
private var hookInstallerPaths: ActivityBarHookInstallerPaths
private var inputEventNotificationTask: Task<Void, Never>?
private var terminateObserver: NSObjectProtocol?
init(
context: PluginRuntimeContext,
inputMonitor: (any ActivityBarInputMonitoring)? = nil,
socketServer: (any ActivityBarSocketServing)? = nil,
inputStats: ActivityBarStatsStore? = nil,
codingStats: ActivityBarCodingSessionStore? = nil,
hookInstallerPaths: ActivityBarHookInstallerPaths? = nil,
localization: PluginLocalization = PluginLocalization(bundle: .main),
inputEventNotificationDelay: Duration = .milliseconds(750)
) {
let resolvedInputStats = inputStats ?? ActivityBarStatsStore(storage: context.storage)
let resolvedCodingStats = codingStats ?? ActivityBarCodingSessionStore(storage: context.storage)
let resolvedInputMonitor = inputMonitor ?? ActivityBarInputMonitor()
let resolvedHookInstallerPaths = hookInstallerPaths
?? ActivityBarHookInstallerPaths.defaults(
supportDirectory: context.supportDirectory,
homeDirectory: FileManager.default.homeDirectoryForCurrentUser
)
let resolvedSocketServer = socketServer ?? ActivityBarHookSocketServer { event in
resolvedCodingStats.handleEvent(event)
}
storage = context.storage
self.localization = localization
self.inputStats = resolvedInputStats
self.codingStats = resolvedCodingStats
self.inputMonitor = resolvedInputMonitor
self.hookInstallerPaths = resolvedHookInstallerPaths
self.socketServer = resolvedSocketServer
self.inputEventNotificationDelay = inputEventNotificationDelay
isTrackingEnabled = context.storage.bool(forKey: StorageKey.isTrackingEnabled)
self.inputMonitor.onEvent = { [weak self] event in
guard let self else {
return
}
self.inputStats.record(event)
self.scheduleInputEventNotification()
}
if let installedAt = context.storage.string(forKey: StorageKey.hooksInstalledAt) {
hookInstallState = .installed(timestamp: installedAt)
}
terminateObserver = NotificationCenter.default.addObserver(
forName: NSApplication.willTerminateNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.inputStats.flushPendingChanges()
self?.codingStats.flushActiveDurations()
}
}
}
@MainActor deinit {
inputEventNotificationTask?.cancel()
if let terminateObserver {
NotificationCenter.default.removeObserver(terminateObserver)
}
}
var hookStatusMessage: String? {
switch hookInstallState {
case .notInstalled:
return nil
case .installed(let timestamp):
return localization.format("hook.status.installedWithDate", defaultValue: "已安装:%@", timestamp)
case .failed:
return localization.string("hook.status.installFailed", defaultValue: "安装失败")
}
}
var isHookListenerRunning: Bool {
socketServer.isRunning
}
var areHooksInstalled: Bool {
if case .installed = hookInstallState {
return true
}
return false
}
var monitorStatus: ActivityBarInputMonitorStatus {
inputMonitor.status
}
var todayInputStats: ActivityBarDailyStats {
inputStats.today
}
var todayCodingStats: ActivityBarCodingDailyStats {
codingStats.today
}
var panelSubtitle: String {
if let lastErrorMessage {
return lastErrorMessage
}
if isTrackingEnabled {
return localization.format(
"panel.subtitle.todayInputs",
defaultValue: "今日 %@ 次输入",
ActivityBarFormatting.count(todayInputStats.totalInputs)
)
}
if isHookListenerRunning {
return localization.string("panel.subtitle.aiListening", defaultValue: "AI 活动监听中")
}
return localization.string("panel.subtitle.default", defaultValue: "统计输入与 AI 编程活动")
}
var componentSubtitle: String {
if isTrackingEnabled {
return localization.format(
"component.subtitle.inputs",
defaultValue: "%@ 次输入",
ActivityBarFormatting.count(todayInputStats.totalInputs)
)
}
if isHookListenerRunning {
return localization.string("component.subtitle.aiListening", defaultValue: "AI 监听中")
}
return localization.string("component.subtitle.disabled", defaultValue: "未开启")
}
var inputMonitoringFootnote: String? {
switch inputMonitor.status {
case .inputMonitoringDenied:
return localization.string(
"settings.inputMonitoring.footnote",
defaultValue: "键盘、鼠标和滚动统计需要在系统设置中允许 MacTools 进行输入监控。前台应用使用时长仍可记录。"
)
case .idle, .running:
return nil
}
}
var hookInstallFootnote: String {
localization.string(
"settings.aiHooks.footnote",
defaultValue: "点击后会写入 Claude Code、Cursor 和 Codex 的 hook 配置;AI 活动监听不需要输入监控权限。"
)
}
var hookUninstallFootnote: String {
localization.string(
"settings.aiHooks.uninstallFootnote",
defaultValue: "只移除 MacTools 写入的 Hook 条目和脚本,不会清空其他工具配置。"
)
}
var hookActionFootnote: String {
areHooksInstalled ? hookUninstallFootnote : hookInstallFootnote
}
func activate(context: PluginRuntimeContext) {
hookInstallerPaths = ActivityBarHookInstallerPaths.defaults(
supportDirectory: context.supportDirectory,
homeDirectory: FileManager.default.homeDirectoryForCurrentUser
)
startHookListenerIfNeeded()
if isTrackingEnabled {
startInputTracking()
}
}
func deactivate(reason: PluginDeactivationReason) {
stopRuntime()
inputEventNotificationTask?.cancel()
inputEventNotificationTask = nil
}
func refresh() {
inputStats.flushPendingChanges()
codingStats.flushActiveDurations()
notifyChange()
}
func setTrackingEnabled(_ enabled: Bool) {
isTrackingEnabled = enabled
storage.set(enabled, forKey: StorageKey.isTrackingEnabled)
if enabled {
startInputTracking()
startHookListenerIfNeeded()
} else {
stopInputTracking()
if !shouldRunHookListener {
lastErrorMessage = nil
}
}
notifyChange()
}
func resetToday() {
inputStats.resetToday()
codingStats.resetToday()
notifyChange()
}
func installHooks() {
let installer = ActivityBarHookInstaller(paths: hookInstallerPaths)
do {
let summary = try installer.install()
let timestamp = Self.installTimestamp()
storage.set(timestamp, forKey: StorageKey.hooksInstalledAt)
hookInstallState = .installed(timestamp: timestamp)
lastErrorMessage = nil
startHookListenerIfNeeded()
ActivityBarLog.hooks.info(
"Activity bar hooks installed in \(summary.scriptDirectory.path, privacy: .public)"
)
} catch {
lastErrorMessage = localization.format(
"error.hook.installFailed",
defaultValue: "Hook 安装失败:%@",
localizedDescription(for: error)
)
hookInstallState = .failed
ActivityBarLog.hooks.error("Activity bar hook installation failed: \(error.localizedDescription, privacy: .public)")
}
notifyChange()
}
func uninstallHooks() {
let installer = ActivityBarHookInstaller(paths: hookInstallerPaths)
do {
let summary = try installer.uninstall()
storage.removeObject(forKey: StorageKey.hooksInstalledAt)
hookInstallState = .notInstalled
lastErrorMessage = nil
socketServer.stop()
codingStats.flushActiveDurations()
ActivityBarLog.hooks.info(
"Activity bar hooks uninstalled from \(summary.scriptDirectory.path, privacy: .public)"
)
} catch {
lastErrorMessage = localization.format(
"error.hook.uninstallFailed",
defaultValue: "Hook 卸载失败:%@",
localizedDescription(for: error)
)
hookInstallState = .failed
ActivityBarLog.hooks.error("Activity bar hook uninstallation failed: \(error.localizedDescription, privacy: .public)")
}
notifyChange()
}
func openInputMonitoringSettings() {
openPrivacyPane(anchor: "Privacy_ListenEvent")
}
private var shouldRunHookListener: Bool {
if case .installed = hookInstallState {
return true
}
return false
}
private func startInputTracking() {
inputMonitor.start()
}
private func startHookListenerIfNeeded() {
guard shouldRunHookListener else {
return
}
do {
try socketServer.start()
} catch {
lastErrorMessage = localization.format(
"error.socket.startFailed",
defaultValue: "AI 活动监听启动失败:%@",
localizedDescription(for: error)
)
ActivityBarLog.socket.error("Activity bar socket start failed: \(error.localizedDescription, privacy: .public)")
}
}
private func stopRuntime() {
stopInputTracking()
socketServer.stop()
codingStats.flushActiveDurations()
}
private func stopInputTracking() {
inputMonitor.stop()
inputStats.flushPendingChanges()
}
private func openPrivacyPane(anchor: String) {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") else {
return
}
NSWorkspace.shared.open(url)
}
private func notifyChange() {
inputEventNotificationTask?.cancel()
inputEventNotificationTask = nil
objectWillChange.send()
onStateChange?()
}
private func scheduleInputEventNotification() {
guard inputEventNotificationTask == nil else {
return
}
inputEventNotificationTask = Task { @MainActor [weak self] in
guard let self else {
return
}
do {
try await Task.sleep(for: inputEventNotificationDelay)
} catch {
return
}
guard !Task.isCancelled else {
return
}
inputEventNotificationTask = nil
objectWillChange.send()
onStateChange?()
}
}
private func localizedDescription(for error: Error) -> String {
if let hookError = error as? ActivityBarHookInstallerError {
return hookError.localizedDescription(localization: localization)
}
if let socketError = error as? ActivityBarSocketError {
return socketError.localizedDescription(localization: localization)
}
return error.localizedDescription
}
private static func installTimestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.string(from: Date())
}
}