diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e7b95eb
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: macos-15
+ timeout-minutes: 20
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Run self-test
+ run: swift run --disable-sandbox AgentAwakeSelfTest
+
+ - name: Build architecture-specific release images
+ run: ./scripts/package-release.sh
+
+ - name: Verify release images and checksums
+ run: |
+ test -f dist/release/AgentAwake-*-Apple-Silicon.dmg
+ test -f dist/release/AgentAwake-*-Intel.dmg
+ cd dist/release
+ shasum -a 256 -c AgentAwake-*.sha256
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..2436f85
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,38 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: macos-15
+ timeout-minutes: 30
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Verify tag matches app version
+ run: |
+ version="$(/usr/libexec/PlistBuddy \
+ -c 'Print :CFBundleShortVersionString' \
+ Resources/Info.plist)"
+ test "${GITHUB_REF_NAME#v}" = "$version"
+
+ - name: Build ad-hoc Apple Silicon and Intel images
+ run: ./scripts/package-release.sh
+
+ - name: Publish GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "$GITHUB_REF_NAME" \
+ dist/release/AgentAwake-*.dmg \
+ dist/release/AgentAwake-*.sha256 \
+ --title "AgentAwake ${GITHUB_REF_NAME#v}" \
+ --generate-notes
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5151d2e..5bfd543 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,10 +13,38 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
## [Unreleased / 未发布]
-
+## [0.4.0] - 2026-07-31
+
+### Added / 新增
+
+- 分别提供 Apple Silicon 与 Intel Mac 的独立可下载 App 和 DMG。
+ Separate downloadable apps and DMGs for Apple silicon and Intel Macs.
+- App 内设置窗口,可选管理登录项、Codex Hooks 与 Claude Hooks。
+ In-app settings for the login item and optional Codex/Claude Hooks.
+- 首次启动自动展示菜单栏面板;定时模式无需任何 Agent 或额外配置。
+ First launch reveals the menu bar panel; timed modes need no agent or setup.
+- Ad-hoc 签名、双架构 DMG 制作和无需 Apple 发布凭据的 GitHub Release 自动化。
+ Ad-hoc signing, dual-architecture DMG packaging, and GitHub Release
+ automation without Apple publishing credentials.
+
+### Changed / 变更
+
+- Hooks 安装从命令行前置步骤改为设置中的可选增强,且只修改用户明确选择、
+ 本机已检测到的 Agent 配置。
+ Hooks are now optional settings and only modify explicitly selected,
+ locally detected agent configurations.
+- 五档滑轨加入悬停光晕、加大的圆形把手、加粗轨道和带触觉反馈的弹性吸附。
+ The five-position slider now has a hover glow, a larger circular thumb,
+ a thicker track, haptic feedback, and spring-loaded snapping.
+- 修复菜单栏面板首次显示时,第一次拖拽只激活窗口而不会移动滑块的问题。
+ Fixed the first drag being consumed by window activation when the menu bar
+ popover is shown for the first time.
+- 菜单栏与 App 内标记统一为四角十字星和单个左倾 `Z`,并将状态栏占位收为方形。
+ The menu bar and in-app marks now share a four-point sparkle with one
+ left-leaning `Z`, while the status item uses a compact square footprint.
+- APP 图标同步换为暖陶土橙配色的四角十字星与单 `Z` 版本。
+ The app icon now uses the same four-point sparkle and single `Z` in a warm
+ clay-orange palette.
## [0.3.1] - 2026-07-31
diff --git a/Package.swift b/Package.swift
index 3fd78eb..b7e72c2 100644
--- a/Package.swift
+++ b/Package.swift
@@ -13,6 +13,10 @@ let package = Package(
name: "AgentAwakeCore",
targets: ["AgentAwakeCore"]
),
+ .library(
+ name: "AgentAwakeSetupCore",
+ targets: ["AgentAwakeSetupCore"]
+ ),
.executable(
name: "AgentAwake",
targets: ["AgentAwake"]
@@ -35,18 +39,25 @@ let package = Package(
),
.executableTarget(
name: "AgentAwake",
- dependencies: ["AgentAwakeCore"]
+ dependencies: ["AgentAwakeCore", "AgentAwakeSetupCore"],
+ linkerSettings: [
+ .linkedFramework("ServiceManagement")
+ ]
),
.executableTarget(
name: "AgentAwakeHook",
dependencies: ["AgentAwakeCore"]
),
.executableTarget(
- name: "AgentAwakeHookSetup"
+ name: "AgentAwakeHookSetup",
+ dependencies: ["AgentAwakeSetupCore"]
+ ),
+ .target(
+ name: "AgentAwakeSetupCore"
),
.executableTarget(
name: "AgentAwakeSelfTest",
- dependencies: ["AgentAwakeCore"]
+ dependencies: ["AgentAwakeCore", "AgentAwakeSetupCore"]
),
.executableTarget(
name: "AgentAwakePowerProbe",
diff --git a/README.md b/README.md
index cf0ae27..c3aa0e9 100644
--- a/README.md
+++ b/README.md
@@ -14,12 +14,16 @@
- macOS 13+ · Swift · 当前版本 0.3.1
+ macOS 13+ · Apple Silicon / Intel · 当前版本 0.4.0
+
+
+
+ 下载最新版本
## 软件介绍
-AgentAwake 是一个为 Codex 和 Claude 长任务设计的轻量 macOS 菜单栏工具。
+AgentAwake 是一个下载后即可使用的轻量 macOS 菜单栏工具。
当定时保护开启或 Agent 正在工作时,它通过带超时保护的 macOS IOKit
临时电源断言阻止空闲休眠;任务结束、倒计时结束、手动关闭或退出 App 后,
它会立即释放断言,把休眠控制权交还给系统。
@@ -35,6 +39,8 @@ AgentAwake 是一个为 Codex 和 Claude 长任务设计的轻量 macOS 菜单
保持系统和显示器唤醒。
- **Hooks 优先、日志兜底**:优先使用生命周期 Hooks 获取及时状态;未安装 Hooks
时,自动增量读取本地任务日志作为零配置兜底,不依赖可能长期常驻的进程名。
+- **下载后直接使用**:普通用户不需要 Codex、Cloud、Xcode、Swift 或终端;
+ Hooks 与登录项等增强能力都在设置中按需启用。
- **自动释放与续租**:Agent 模式使用 120 秒短租约,每 60 秒续期;最后一个
Agent 结束 5 秒后自动释放,异常失联的状态也会过期。
- **安全的临时断言**:固定时长和 Agent 模式均使用带超时的 IOKit 断言,
@@ -57,43 +63,54 @@ AgentAwake 是一个为 Codex 和 Claude 长任务设计的轻量 macOS 菜单
## 系统要求
- macOS 13 Ventura 或更高版本
-- 从源码构建时需要 Swift 工具链;安装 Xcode Command Line Tools 即可
+- 支持 Apple Silicon 与 Intel Mac
- 当前菜单栏界面为简体中文
## 使用方法
-### 1. 构建并启动
+### 1. 下载并启动
-进入仓库目录后运行:
+前往 [GitHub Releases](https://github.com/Raymond711xl/AgentAwake/releases/latest):
-```bash
-chmod +x scripts/build-app.sh scripts/build-icon.sh
-./scripts/build-app.sh
-open dist/AgentAwake.app
-```
+1. 按本机芯片下载对应文件:
+ - M1、M2、M3、M4 等苹果芯片:`AgentAwake-x.y.z-Apple-Silicon.dmg`;
+ - Intel 芯片:`AgentAwake-x.y.z-Intel.dmg`。
+2. 双击 DMG,在打开的窗口中双击 `AgentAwake.app` 即可使用;如果准备长期使用,
+ 建议先将 App 拖入“应用程序”,尤其是在启用“登录时启动”前。
-构建脚本会生成并临时签名 `dist/AgentAwake.app`,同时把 APP 图标、主程序和
-Hooks helper 打包进去。首次启动后,AgentAwake 只出现在菜单栏,不显示 Dock
-图标。
+如果不知道芯片类型,点击苹果菜单 →“关于本机”,查看“芯片”或“处理器”。
+
+> **首次打开被 macOS 阻止时:**先尝试双击一次并关闭提示,然后打开
+> “系统设置 → 隐私与安全性”,向下滚动,在 AgentAwake 提示旁点击“仍要打开”,
+> 再确认“打开”(可能需要输入登录密码)。这只需操作一次,以后可以直接双击。
+> 本版本采用 ad-hoc 签名,因此不会显示已验证的 Apple 开发者身份;不需要终端命令。
+
+首次启动后,AgentAwake 会自动展开一次菜单栏面板,之后只保留菜单栏图标,
+不显示 Dock 图标。三个定时模式此时已经可以使用,不需要继续配置。
### 2. 选择保护模式
-1. 点击菜单栏中的星星与三个 `Z` 图标。
+1. 点击菜单栏中的四角十字星与单个 `Z` 图标。
2. 拖动滑块选择 30 分钟、1 小时、2 小时或 Agent。
3. 需要立即停止时,把滑块拖回“未开启”;临时断言会马上释放。
固定时长模式会立即开始倒计时。Agent 模式在没有任务时只等待,
检测到 Codex 或 Claude 工作后才接管休眠。
-### 3. 安装 Agent Hooks(推荐)
+### 3. 可选设置
-先完成 App 构建,再运行幂等安装器:
+点击菜单栏面板底部的“设置”,可以按需管理:
-```bash
-./dist/AgentAwake.app/Contents/Helpers/AgentAwakeHookSetup install
-```
+- 登录时启动;
+- Codex Hooks;
+- Claude Hooks。
-安装器会:
+Agent 模式默认读取本机已有的 Codex 与 Claude 活动记录,因此 Hooks 不是使用
+前置条件。设置页会标明本机检测状态,只允许为实际存在的 Agent 安装 Hooks,
+不会为未安装的软件创建配置。
+AgentAwake 不负责安装或运行大模型;相关 Agent 由用户自行配置,App 只读取
+已有的本地状态。
+用户明确点击安装后,App 才会:
- 把一次性 Hook helper 安装到
`~/Library/Application Support/AgentAwake/bin/AgentAwakeHook`;
@@ -105,14 +122,8 @@ Codex 会要求审核非托管命令 Hook。安装后在 Codex CLI 输入 `/hook
检查命令路径并信任新增的 AgentAwake Hooks;未信任前 Codex 会跳过它们。
Claude 的用户级设置会自动加载。
-不安装 Hooks 也可以使用 Agent 模式,App 会自动启用本地任务日志兜底,
-只是状态变化可能不如 Hooks 及时。
-
-移除适配器:
-
-```bash
-./dist/AgentAwake.app/Contents/Helpers/AgentAwakeHookSetup uninstall
-```
+不安装 Hooks 也可以使用 Agent 模式,只是状态变化可能不如 Hooks 及时。
+安装、修复和移除均在设置页完成,不需要终端。
## 如何确认没有修改系统设置
@@ -155,6 +166,14 @@ app-server、sandbox 和流式进程可能长期常驻。
## 开发与验证
+从源码构建需要 Swift 工具链;安装 Xcode Command Line Tools 即可。
+
+构建 Apple Silicon 与 Intel 发布镜像:
+
+```bash
+./scripts/package-release.sh
+```
+
运行零依赖自检:
```bash
@@ -174,14 +193,16 @@ swift run --disable-sandbox AgentAwakeSelfTest
```text
Sources/AgentAwake/ 菜单栏界面与 App 生命周期
Sources/AgentAwakeCore/ 保护会话、Agent 检测与 IOKit 断言
+Sources/AgentAwakeSetupCore/ 可选集成的检测、安装、修复与移除
Sources/AgentAwakeHook/ 单次生命周期 Hook helper
Sources/AgentAwakeHookSetup/ Hooks 安装与卸载
Resources/ Info.plist 与 APP 图标
scripts/ App 和图标构建脚本
+docs/RELEASING.md 双架构 DMG 与 GitHub Release 流程
```
## 当前边界
-AgentAwake 当前面向本机运行的 Codex 与 Claude,并以源码构建方式提供。
-它不是系统电源设置管理器,也不会替代 macOS 原有的睡眠策略;它只在所选窗口内
-临时延后空闲休眠。
+AgentAwake 的定时模式不依赖任何 Agent;Agent 感知模式当前支持本机运行的
+Codex 与 Claude。它不是系统电源设置管理器,也不会替代 macOS 原有的睡眠
+策略;它只在所选窗口内临时延后空闲休眠。
diff --git a/README_EN.md b/README_EN.md
index 85a16d8..f47ee71 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -14,13 +14,17 @@
- macOS 13+ · Swift · Current version 0.3.1
+ macOS 13+ · Apple Silicon / Intel · Current version 0.4.0
+
+
+
+ Download the latest release
## About
-AgentAwake is a lightweight macOS menu bar utility for long-running Codex and
-Claude tasks. While a timed mode is active or an agent is working, it prevents
+AgentAwake is a lightweight macOS menu bar utility that is ready to use after
+download. While a timed mode is active or an agent is working, it prevents
idle sleep with timeout-protected macOS IOKit power assertions. When the task
finishes, the timer expires, the mode is turned off, or the app quits,
AgentAwake immediately releases those assertions and returns control to macOS.
@@ -37,6 +41,8 @@ or keeps a persistent `caffeinate` or shell process running.
- **Hooks first, local logs as fallback** — Uses lifecycle Hooks for timely
state changes. Without Hooks, it incrementally reads local task logs for a
zero-configuration fallback instead of trusting long-lived process names.
+- **Download and run** — Regular users need no Codex, Cloud, Xcode, Swift, or
+ terminal. Hooks and login-item behavior are optional settings.
- **Automatic release and renewal** — Agent mode uses a 120-second lease,
renews it every 60 seconds, and releases it five seconds after the last agent
finishes. Stale activity also expires automatically.
@@ -62,28 +68,38 @@ or keeps a persistent `caffeinate` or shell process running.
## Requirements
- macOS 13 Ventura or later
-- A Swift toolchain for source builds; Xcode Command Line Tools are sufficient
+- Apple silicon and Intel Macs are supported
- The current menu bar interface is in Simplified Chinese
## Usage
-### 1. Build and launch
+### 1. Download and launch
-From the repository root:
+Open [GitHub Releases](https://github.com/Raymond711xl/AgentAwake/releases/latest):
-```bash
-chmod +x scripts/build-app.sh scripts/build-icon.sh
-./scripts/build-app.sh
-open dist/AgentAwake.app
-```
+1. Download the image for your Mac:
+ - Apple M1, M2, M3, M4, or later: `AgentAwake-x.y.z-Apple-Silicon.dmg`.
+ - Intel processor: `AgentAwake-x.y.z-Intel.dmg`.
+2. Open the DMG and double-click `AgentAwake.app`. For long-term use, drag the
+ app to Applications first, especially before enabling Launch at Login.
-The build script creates an ad-hoc signed `dist/AgentAwake.app` containing the
-app icon, main executable, and Hook helpers. Once launched, AgentAwake appears
-only in the menu bar and does not show a Dock icon.
+To check your processor, open Apple menu → About This Mac and look for Chip
+or Processor.
+
+> **If macOS blocks the first launch:** try opening AgentAwake once and dismiss
+> the warning. Open System Settings → Privacy & Security, scroll down, click
+> Open Anyway beside AgentAwake, then confirm Open. macOS may ask for your login
+> password. This is a one-time exception; future launches work normally.
+> This release uses ad-hoc signing and does not show a verified Apple developer.
+> No Terminal command is required.
+
+On its first launch, AgentAwake opens its menu bar panel once. It then remains
+in the menu bar without a Dock icon. All three timed modes are immediately
+available and require no further setup.
### 2. Choose a protection mode
-1. Click the star-and-three-`Z` icon in the menu bar.
+1. Click the four-point sparkle-and-`Z` icon in the menu bar.
2. Drag the slider to 30 minutes, 1 hour, 2 hours, or Agent.
3. To stop immediately, drag it back to Off (`未开启`); the temporary power
assertions are released at once.
@@ -91,36 +107,38 @@ only in the menu bar and does not show a Dock icon.
Timed modes start their countdown immediately. Agent mode waits without holding
an assertion, then takes over only when it detects active Codex or Claude work.
-### 3. Install Agent Hooks (recommended)
+### 3. Optional settings
-Build the app first, then run the idempotent installer:
+Click Settings at the bottom of the menu bar panel to manage:
-```bash
-./dist/AgentAwake.app/Contents/Helpers/AgentAwakeHookSetup install
-```
+- launch at login;
+- Codex Hooks;
+- Claude Hooks.
-The installer:
+Agent mode reads existing local Codex and Claude activity records by default,
+so Hooks are not a prerequisite. Settings reports local detection state and
+only enables Hook installation for agents that exist on the Mac. It never
+creates configuration for absent software. Only after the user explicitly
+selects Install does the app:
-- places the one-shot helper at
+- place the one-shot helper at
`~/Library/Application Support/AgentAwake/bin/AgentAwakeHook`;
-- safely merges entries into `~/.codex/hooks.json` and
+- safely merge entries into `~/.codex/hooks.json` and
`~/.claude/settings.json`;
-- creates an `.agentawake-backup` before it first changes an existing config;
-- updates its own entries on repeated runs instead of appending duplicates.
+- create an `.agentawake-backup` before it first changes an existing config;
+- update its own entries on repeated runs instead of appending duplicates.
+
+AgentAwake does not install or run a model. Users configure their own agent
+software, and the app only reads existing local state.
Codex asks you to review unmanaged command Hooks. After installation, enter
`/hooks` in the Codex CLI, inspect the command path, and trust the new
AgentAwake Hooks. Codex skips them until they are trusted. Claude loads its
user-level settings automatically.
-Agent mode still works without Hooks by using the local task-log fallback,
-although state transitions may be less immediate.
-
-To remove the adapters:
-
-```bash
-./dist/AgentAwake.app/Contents/Helpers/AgentAwakeHookSetup uninstall
-```
+Agent mode still works without Hooks, although state transitions may be less
+immediate. Installation, repair, and removal all happen in Settings without a
+terminal.
## Verify that system settings remain unchanged
@@ -167,6 +185,15 @@ automatically.
## Development and verification
+Source builds require a Swift toolchain; Xcode Command Line Tools are
+sufficient.
+
+Build the Apple silicon and Intel release images:
+
+```bash
+./scripts/package-release.sh
+```
+
Run the dependency-free self-test:
```bash
@@ -186,15 +213,17 @@ Project layout:
```text
Sources/AgentAwake/ Menu bar UI and app lifecycle
Sources/AgentAwakeCore/ Protection state, agent detection, IOKit assertions
+Sources/AgentAwakeSetupCore/ Optional integration detection and management
Sources/AgentAwakeHook/ One-shot lifecycle Hook helper
Sources/AgentAwakeHookSetup/ Hook installer and uninstaller
Resources/ Info.plist and app icon
scripts/ App and icon build scripts
+docs/RELEASING.md Dual-architecture DMG and GitHub Release workflow
```
## Current scope
-AgentAwake currently targets locally running Codex and Claude tasks and is
-distributed as a source build. It is not a system power-settings manager and
-does not replace macOS sleep policy; it only postpones idle sleep during the
-selected protection window.
+AgentAwake timed modes do not require an agent. Agent-aware mode currently
+supports locally running Codex and Claude tasks. It is not a system
+power-settings manager and does not replace macOS sleep policy; it only
+postpones idle sleep during the selected protection window.
diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns
index 98752e9..50f62e0 100644
Binary files a/Resources/AppIcon.icns and b/Resources/AppIcon.icns differ
diff --git a/Resources/AppIcon.png b/Resources/AppIcon.png
index 324dfd7..a351813 100644
Binary files a/Resources/AppIcon.png and b/Resources/AppIcon.png differ
diff --git a/Resources/Info.plist b/Resources/Info.plist
index f2fa80d..6659603 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -19,13 +19,19 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.3.1
+ 0.4.0
CFBundleVersion
- 4
+ 5
+ LSApplicationCategoryType
+ public.app-category.utilities
LSMinimumSystemVersion
13.0
+ LSMultipleInstancesProhibited
+
LSUIElement
+ NSHumanReadableCopyright
+ Copyright © 2026 Raymond. All rights reserved.
NSHighResolutionCapable
diff --git a/Sources/AgentAwake/AppDelegate.swift b/Sources/AgentAwake/AppDelegate.swift
index bbce45b..4662903 100644
--- a/Sources/AgentAwake/AppDelegate.swift
+++ b/Sources/AgentAwake/AppDelegate.swift
@@ -2,14 +2,34 @@ import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate {
private var appController: AppController?
+ private var integrationSettingsController: IntegrationSettingsController?
+ private var settingsWindowController: SettingsWindowController?
private var statusItemController: StatusItemController?
func applicationDidFinishLaunching(_ notification: Notification) {
NSApplication.shared.setActivationPolicy(.accessory)
let appController = AppController()
+ let integrationSettingsController = IntegrationSettingsController()
+ let settingsWindowController = SettingsWindowController(
+ settingsController: integrationSettingsController
+ )
self.appController = appController
- statusItemController = StatusItemController(appController: appController)
+ self.integrationSettingsController = integrationSettingsController
+ self.settingsWindowController = settingsWindowController
+ let statusItemController = StatusItemController(
+ appController: appController,
+ onOpenSettings: settingsWindowController.present
+ )
+ self.statusItemController = statusItemController
+
+ let defaults = UserDefaults.standard
+ if !defaults.bool(forKey: "hasPresentedInitialPopover") {
+ defaults.set(true, forKey: "hasPresentedInitialPopover")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
+ statusItemController.presentPopover()
+ }
+ }
}
func applicationWillTerminate(_ notification: Notification) {
diff --git a/Sources/AgentAwake/IntegrationSettingsController.swift b/Sources/AgentAwake/IntegrationSettingsController.swift
new file mode 100644
index 0000000..c82c6d4
--- /dev/null
+++ b/Sources/AgentAwake/IntegrationSettingsController.swift
@@ -0,0 +1,94 @@
+import AgentAwakeSetupCore
+import Foundation
+import ServiceManagement
+
+@MainActor
+final class IntegrationSettingsController: ObservableObject {
+ @Published private(set) var integrations: [AgentIntegrationSnapshot] = []
+ @Published private(set) var workingProvider: AgentIntegrationProvider?
+ @Published private(set) var feedbackText: String?
+ @Published private(set) var launchAtLoginEnabled = false
+ @Published private(set) var launchAtLoginNeedsApproval = false
+ @Published private(set) var isChangingLaunchAtLogin = false
+
+ private let manager: AgentIntegrationManager
+ private let loginService: SMAppService
+
+ init(
+ manager: AgentIntegrationManager? = nil,
+ loginService: SMAppService = .mainApp
+ ) {
+ let helperURL = Bundle.main.bundleURL
+ .appendingPathComponent("Contents", isDirectory: true)
+ .appendingPathComponent("Helpers", isDirectory: true)
+ .appendingPathComponent("AgentAwakeHook")
+ self.manager = manager ?? AgentIntegrationManager(
+ bundledHelperURL: helperURL
+ )
+ self.loginService = loginService
+ refresh()
+ }
+
+ func refresh() {
+ integrations = manager.inspectAll()
+ launchAtLoginEnabled = loginService.status == .enabled
+ launchAtLoginNeedsApproval = loginService.status == .requiresApproval
+ }
+
+ func install(_ provider: AgentIntegrationProvider) {
+ guard workingProvider == nil else {
+ return
+ }
+
+ workingProvider = provider
+ feedbackText = nil
+ do {
+ try manager.install(provider)
+ feedbackText = "\(provider.displayName) Hooks 已安装。"
+ } catch {
+ feedbackText = error.localizedDescription
+ }
+ workingProvider = nil
+ refresh()
+ }
+
+ func uninstall(_ provider: AgentIntegrationProvider) {
+ guard workingProvider == nil else {
+ return
+ }
+
+ workingProvider = provider
+ feedbackText = nil
+ do {
+ try manager.uninstall(provider)
+ feedbackText = "\(provider.displayName) Hooks 已移除。"
+ } catch {
+ feedbackText = error.localizedDescription
+ }
+ workingProvider = nil
+ refresh()
+ }
+
+ func setLaunchAtLogin(_ enabled: Bool) {
+ guard !isChangingLaunchAtLogin else {
+ return
+ }
+
+ isChangingLaunchAtLogin = true
+ feedbackText = nil
+
+ Task { @MainActor in
+ do {
+ if enabled {
+ try loginService.register()
+ } else {
+ try await loginService.unregister()
+ }
+ } catch {
+ feedbackText = "无法更新登录项:\(error.localizedDescription)"
+ }
+ isChangingLaunchAtLogin = false
+ refresh()
+ }
+ }
+}
diff --git a/Sources/AgentAwake/ProtectionModeSlider.swift b/Sources/AgentAwake/ProtectionModeSlider.swift
index d3d95a2..6e1e3d4 100644
--- a/Sources/AgentAwake/ProtectionModeSlider.swift
+++ b/Sources/AgentAwake/ProtectionModeSlider.swift
@@ -6,9 +6,14 @@ struct ProtectionModeSlider: View {
let selection: ProtectionMode
let onSelect: (ProtectionMode) -> Void
- private let thumbDiameter: CGFloat = 15
- private let trackHeight: CGFloat = 3
- private let controlHeight: CGFloat = 22
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var isHovering = false
+ @State private var isDragging = false
+
+ private let thumbDiameter: CGFloat = 19
+ private let trackHeight: CGFloat = 5
+ private let stopDiameter: CGFloat = 5
+ private let controlHeight: CGFloat = 28
var body: some View {
GeometryReader { geometry in
@@ -17,26 +22,53 @@ struct ProtectionModeSlider: View {
let usableWidth = max(1, width - thumbDiameter)
let progress = CGFloat(selection.rawValue) / 4
let thumbCenterX = radius + progress * usableWidth
+ let centerY = controlHeight / 2
ZStack(alignment: .topLeading) {
Capsule()
- .fill(Color(nsColor: .separatorColor).opacity(0.9))
+ .fill(
+ Color.accentColor.opacity(
+ isHovering || isDragging ? 0.075 : 0
+ )
+ )
+ .overlay {
+ Capsule()
+ .stroke(
+ Color.accentColor.opacity(
+ isHovering || isDragging ? 0.16 : 0
+ ),
+ lineWidth: 1
+ )
+ }
+ .frame(width: width, height: controlHeight - 2)
+ .position(x: width / 2, y: centerY)
+
+ Capsule()
+ .fill(
+ Color(nsColor: .separatorColor).opacity(
+ isHovering || isDragging ? 1 : 0.82
+ )
+ )
.frame(width: usableWidth, height: trackHeight)
.position(
x: radius + usableWidth / 2,
- y: controlHeight / 2
+ y: centerY
)
if selection.rawValue > 0 {
Capsule()
- .fill(Color.primary.opacity(0.58))
+ .fill(
+ Color.accentColor.opacity(
+ isHovering || isDragging ? 0.94 : 0.76
+ )
+ )
.frame(
width: progress * usableWidth,
height: trackHeight
)
.position(
x: radius + progress * usableWidth / 2,
- y: controlHeight / 2
+ y: centerY
)
}
@@ -46,13 +78,16 @@ struct ProtectionModeSlider: View {
Circle()
.fill(
rawValue <= selection.rawValue
- ? Color.primary.opacity(0.72)
- : Color.secondary.opacity(0.48)
+ ? Color.accentColor.opacity(0.95)
+ : Color.secondary.opacity(0.52)
+ )
+ .frame(
+ width: stopDiameter,
+ height: stopDiameter
)
- .frame(width: 4, height: 4)
.position(
x: radius + stopProgress * usableWidth,
- y: controlHeight / 2
+ y: centerY
)
}
@@ -60,7 +95,7 @@ struct ProtectionModeSlider: View {
.fill(
selection.isOff
? Color.secondary
- : Color.primary
+ : Color.accentColor
)
.frame(
width: thumbDiameter,
@@ -70,18 +105,32 @@ struct ProtectionModeSlider: View {
Circle()
.stroke(
Color(nsColor: .windowBackgroundColor)
- .opacity(0.72),
- lineWidth: 1
+ .opacity(0.88),
+ lineWidth: 1.25
)
}
+ .overlay {
+ Circle()
+ .fill(Color.white.opacity(0.9))
+ .frame(width: 5, height: 5)
+ }
+ .shadow(
+ color: Color.accentColor.opacity(
+ isDragging ? 0.5 : isHovering ? 0.34 : 0.1
+ ),
+ radius: isDragging ? 7 : isHovering ? 5 : 2
+ )
.shadow(
- color: Color.black.opacity(0.2),
- radius: 1.5,
+ color: Color.black.opacity(0.22),
+ radius: 2,
y: 1
)
+ .scaleEffect(
+ isDragging ? 1.12 : isHovering ? 1.07 : 1
+ )
.position(
x: thumbCenterX,
- y: controlHeight / 2
+ y: centerY
)
}
.frame(width: width, height: controlHeight)
@@ -89,22 +138,37 @@ struct ProtectionModeSlider: View {
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
+ if !isDragging {
+ withAnimation(hoverAnimation) {
+ isDragging = true
+ }
+ }
selectMode(
at: value.location.x,
- width: width
+ width: width,
+ givesFeedback: true
)
}
.onEnded { value in
selectMode(
at: value.location.x,
- width: width
+ width: width,
+ givesFeedback: true
)
+ withAnimation(snapAnimation) {
+ isDragging = false
+ }
}
)
+ .onHover { hovering in
+ withAnimation(hoverAnimation) {
+ isHovering = hovering
+ }
+ }
}
.frame(height: controlHeight)
.animation(
- .easeOut(duration: 0.12),
+ snapAnimation,
value: selection.rawValue
)
.accessibilityElement(children: .ignore)
@@ -113,9 +177,15 @@ struct ProtectionModeSlider: View {
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
- selectRawValue(selection.rawValue + 1)
+ selectRawValue(
+ selection.rawValue + 1,
+ givesFeedback: false
+ )
case .decrement:
- selectRawValue(selection.rawValue - 1)
+ selectRawValue(
+ selection.rawValue - 1,
+ givesFeedback: false
+ )
@unknown default:
break
}
@@ -123,7 +193,25 @@ struct ProtectionModeSlider: View {
.help("拖动圆形滑块选择防休眠模式")
}
- private func selectMode(at xPosition: CGFloat, width: CGFloat) {
+ private var hoverAnimation: Animation {
+ reduceMotion ? .linear(duration: 0.01) : .easeOut(duration: 0.16)
+ }
+
+ private var snapAnimation: Animation {
+ reduceMotion
+ ? .linear(duration: 0.01)
+ : .interactiveSpring(
+ response: 0.24,
+ dampingFraction: 0.7,
+ blendDuration: 0.08
+ )
+ }
+
+ private func selectMode(
+ at xPosition: CGFloat,
+ width: CGFloat,
+ givesFeedback: Bool
+ ) {
let radius = thumbDiameter / 2
let usableWidth = max(1, width - thumbDiameter)
let clampedX = min(
@@ -131,10 +219,16 @@ struct ProtectionModeSlider: View {
width - radius
)
let progress = (clampedX - radius) / usableWidth
- selectRawValue(Int((progress * 4).rounded()))
+ selectRawValue(
+ Int((progress * 4).rounded()),
+ givesFeedback: givesFeedback
+ )
}
- private func selectRawValue(_ rawValue: Int) {
+ private func selectRawValue(
+ _ rawValue: Int,
+ givesFeedback: Bool
+ ) {
let clampedRawValue = min(max(rawValue, 0), 4)
guard let mode = ProtectionMode(rawValue: clampedRawValue),
mode != selection
@@ -142,6 +236,15 @@ struct ProtectionModeSlider: View {
return
}
- onSelect(mode)
+ if givesFeedback && !reduceMotion {
+ NSHapticFeedbackManager.defaultPerformer.perform(
+ .alignment,
+ performanceTime: .now
+ )
+ }
+
+ withAnimation(snapAnimation) {
+ onSelect(mode)
+ }
}
}
diff --git a/Sources/AgentAwake/SettingsView.swift b/Sources/AgentAwake/SettingsView.swift
new file mode 100644
index 0000000..839aae5
--- /dev/null
+++ b/Sources/AgentAwake/SettingsView.swift
@@ -0,0 +1,272 @@
+import AgentAwakeSetupCore
+import SwiftUI
+
+struct SettingsView: View {
+ @ObservedObject var controller: IntegrationSettingsController
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 22) {
+ header
+ generalSection
+ integrationSection
+
+ if let feedbackText = controller.feedbackText {
+ Label(feedbackText, systemImage: "info.circle")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+
+ footer
+ }
+ .padding(24)
+ }
+ .frame(width: 540, height: 520)
+ .onAppear {
+ controller.refresh()
+ }
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 5) {
+ Text("设置")
+ .font(.system(size: 22, weight: .semibold))
+
+ Text(
+ "AgentAwake 下载后即可使用;" +
+ "下面的项目都不是启动前置条件。"
+ )
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var generalSection: some View {
+ settingsCard(title: "通用", systemImage: "gearshape") {
+ VStack(alignment: .leading, spacing: 12) {
+ Toggle(
+ "登录时启动 AgentAwake",
+ isOn: Binding(
+ get: { controller.launchAtLoginEnabled },
+ set: controller.setLaunchAtLogin
+ )
+ )
+ .disabled(controller.isChangingLaunchAtLogin)
+
+ if controller.launchAtLoginNeedsApproval {
+ Text(
+ "macOS 正在等待你在“系统设置 → 登录项”中" +
+ "允许此项目。"
+ )
+ .font(.system(size: 11))
+ .foregroundStyle(.orange)
+ } else {
+ Text(
+ "App 每次启动仍会回到“未开启”," +
+ "不会自动接管休眠。"
+ )
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ private var integrationSection: some View {
+ settingsCard(title: "Agent 集成(可选)", systemImage: "puzzlepiece") {
+ VStack(alignment: .leading, spacing: 14) {
+ Text(
+ "AgentAwake 不安装或运行大模型,只读取用户已经配置好的" +
+ "本机状态。安装 Hooks 只用于让开始和结束更及时," +
+ "不安装也能使用。"
+ )
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ ForEach(controller.integrations) { integration in
+ integrationRow(integration)
+
+ if integration.id != controller.integrations.last?.id {
+ Divider()
+ }
+ }
+ }
+ }
+ }
+
+ private func integrationRow(
+ _ integration: AgentIntegrationSnapshot
+ ) -> some View {
+ HStack(alignment: .center, spacing: 12) {
+ Image(systemName: providerSymbol(integration.provider))
+ .font(.system(size: 16, weight: .medium))
+ .frame(width: 26, height: 26)
+ .background(
+ Color.primary.opacity(0.06),
+ in: RoundedRectangle(cornerRadius: 7)
+ )
+
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 7) {
+ Text(integration.provider.displayName)
+ .font(.system(size: 13, weight: .medium))
+
+ statusBadge(integration.state)
+ }
+
+ Text(integrationDescription(integration))
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ Spacer(minLength: 12)
+ integrationActions(integration)
+ }
+ }
+
+ @ViewBuilder
+ private func integrationActions(
+ _ integration: AgentIntegrationSnapshot
+ ) -> some View {
+ let isWorking = controller.workingProvider == integration.provider
+
+ switch integration.state {
+ case .notDetected:
+ Button("未检测到") {}
+ .disabled(true)
+
+ case .available:
+ Button(isWorking ? "安装中…" : "安装 Hooks") {
+ controller.install(integration.provider)
+ }
+ .disabled(controller.workingProvider != nil)
+
+ case .installed:
+ Button(isWorking ? "移除中…" : "移除") {
+ controller.uninstall(integration.provider)
+ }
+ .disabled(controller.workingProvider != nil)
+
+ case .needsRepair:
+ Button(isWorking ? "修复中…" : "修复") {
+ controller.install(integration.provider)
+ }
+ .disabled(controller.workingProvider != nil)
+
+ case .invalidConfiguration:
+ Button("需手动检查") {}
+ .disabled(true)
+ }
+ }
+
+ private func statusBadge(
+ _ state: AgentIntegrationState
+ ) -> some View {
+ Text(statusText(state))
+ .font(.system(size: 9, weight: .medium))
+ .foregroundStyle(statusColor(state))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(
+ statusColor(state).opacity(0.1),
+ in: Capsule()
+ )
+ }
+
+ private func settingsCard(
+ title: String,
+ systemImage: String,
+ @ViewBuilder content: () -> Content
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Label(title, systemImage: systemImage)
+ .font(.system(size: 13, weight: .semibold))
+
+ content()
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ Color.primary.opacity(0.045),
+ in: RoundedRectangle(cornerRadius: 12, style: .continuous)
+ )
+ }
+
+ private var footer: some View {
+ HStack {
+ Label("完全本地运行 · 无账号 · 无云服务", systemImage: "lock")
+ Spacer()
+ Text(versionText)
+ }
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ }
+
+ private var versionText: String {
+ let version = Bundle.main.object(
+ forInfoDictionaryKey: "CFBundleShortVersionString"
+ ) as? String ?? "开发版"
+ return "AgentAwake \(version)"
+ }
+
+ private func providerSymbol(
+ _ provider: AgentIntegrationProvider
+ ) -> String {
+ switch provider {
+ case .codex:
+ return "terminal"
+ case .claude:
+ return "text.bubble"
+ }
+ }
+
+ private func statusText(_ state: AgentIntegrationState) -> String {
+ switch state {
+ case .notDetected:
+ return "未检测到"
+ case .available:
+ return "可选"
+ case .installed:
+ return "已安装"
+ case .needsRepair:
+ return "需更新"
+ case .invalidConfiguration:
+ return "配置异常"
+ }
+ }
+
+ private func statusColor(_ state: AgentIntegrationState) -> Color {
+ switch state {
+ case .installed:
+ return .green
+ case .needsRepair, .invalidConfiguration:
+ return .orange
+ case .notDetected, .available:
+ return .secondary
+ }
+ }
+
+ private func integrationDescription(
+ _ integration: AgentIntegrationSnapshot
+ ) -> String {
+ switch integration.state {
+ case .notDetected:
+ return "不会创建 \(integration.provider.displayName) 配置。"
+ case .available where integration.hasLocalActivityData:
+ return "已发现本地活动记录;当前可直接使用日志检测。"
+ case .available:
+ return "已检测到应用;Hooks 尚未安装。"
+ case .installed where integration.provider == .codex:
+ return "Hook 已配置;Codex 首次使用时仍需在 /hooks 中确认信任。"
+ case .installed:
+ return "Hook 已配置,状态变化会更及时。"
+ case .needsRepair:
+ return "已安装的 helper 与当前 App 版本不一致。"
+ case let .invalidConfiguration(message):
+ return message
+ }
+ }
+}
diff --git a/Sources/AgentAwake/SettingsWindowController.swift b/Sources/AgentAwake/SettingsWindowController.swift
new file mode 100644
index 0000000..251494d
--- /dev/null
+++ b/Sources/AgentAwake/SettingsWindowController.swift
@@ -0,0 +1,36 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+final class SettingsWindowController: NSWindowController, NSWindowDelegate {
+ init(settingsController: IntegrationSettingsController) {
+ let hostingController = NSHostingController(
+ rootView: SettingsView(controller: settingsController)
+ )
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 540, height: 520),
+ styleMask: [.titled, .closable, .miniaturizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.title = "AgentAwake 设置"
+ window.contentViewController = hostingController
+ window.isReleasedWhenClosed = false
+ window.center()
+
+ super.init(window: window)
+ window.delegate = self
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func present() {
+ showWindow(nil)
+ window?.center()
+ window?.makeKeyAndOrderFront(nil)
+ NSApplication.shared.activate(ignoringOtherApps: true)
+ }
+}
diff --git a/Sources/AgentAwake/SleepStatusIcon.swift b/Sources/AgentAwake/SleepStatusIcon.swift
index fec1764..692adc8 100644
--- a/Sources/AgentAwake/SleepStatusIcon.swift
+++ b/Sources/AgentAwake/SleepStatusIcon.swift
@@ -2,7 +2,7 @@ import AppKit
import SwiftUI
enum SleepStatusIcon {
- static let menuBarSize = NSSize(width: 27, height: 18)
+ static let menuBarSize = NSSize(width: 18, height: 18)
static func makeImage(
color: NSColor,
@@ -29,7 +29,7 @@ enum SleepStatusIcon {
accessibilityDescription: nil
)?.withSymbolConfiguration(symbolConfiguration) {
sparkle.draw(
- in: NSRect(x: 0, y: 3, width: 13, height: 13),
+ in: NSRect(x: 0, y: 4, width: 13, height: 13),
from: .zero,
operation: .sourceOver,
fraction: 1,
@@ -38,26 +38,21 @@ enum SleepStatusIcon {
)
}
- let zPositions: [
- (point: NSPoint, size: CGFloat)
- ] = [
- (NSPoint(x: 12.5, y: 9.5), 5.5),
- (NSPoint(x: 17, y: 5), 6.5),
- (NSPoint(x: 22, y: 0), 7.5)
+ let zAttributes: [NSAttributedString.Key: Any] = [
+ .font: NSFont.systemFont(ofSize: 7.5, weight: .medium),
+ .foregroundColor: color
]
-
- for position in zPositions {
- let attributes: [NSAttributedString.Key: Any] = [
- .font: NSFont.systemFont(
- ofSize: position.size,
- weight: .semibold
- ),
- .foregroundColor: color
- ]
+ if let context = NSGraphicsContext.current {
+ context.saveGraphicsState()
+ let transform = NSAffineTransform()
+ transform.translateX(by: 10.25, yBy: 0.75)
+ transform.rotate(byDegrees: -8)
+ transform.concat()
("Z" as NSString).draw(
- at: position.point,
- withAttributes: attributes
+ at: .zero,
+ withAttributes: zAttributes
)
+ context.restoreGraphicsState()
}
return true
@@ -73,7 +68,9 @@ struct SleepStatusMark: View {
let isActive: Bool
private var markColor: Color {
- isActive ? .primary : .secondary
+ isActive
+ ? Color(red: 0.85, green: 0.47, blue: 0.34)
+ : .secondary
}
var body: some View {
@@ -83,20 +80,13 @@ struct SleepStatusMark: View {
.offset(x: 0, y: 5)
Text("Z")
- .font(.system(size: 6, weight: .semibold))
- .offset(x: 17, y: 14)
-
- Text("Z")
- .font(.system(size: 7, weight: .semibold))
- .offset(x: 23, y: 8)
-
- Text("Z")
- .font(.system(size: 8, weight: .semibold))
- .offset(x: 30, y: 1)
+ .font(.system(size: 8, weight: .medium, design: .rounded))
+ .rotationEffect(.degrees(-8))
+ .offset(x: 15, y: 0)
}
.foregroundStyle(markColor)
- .frame(width: 39, height: 28, alignment: .topLeading)
+ .frame(width: 28, height: 28, alignment: .topLeading)
.accessibilityElement(children: .ignore)
- .accessibilityLabel("睡眠星标")
+ .accessibilityLabel("睡眠十字星与 Z 标记")
}
}
diff --git a/Sources/AgentAwake/StatusItemController.swift b/Sources/AgentAwake/StatusItemController.swift
index 0565f81..6a0224b 100644
--- a/Sources/AgentAwake/StatusItemController.swift
+++ b/Sources/AgentAwake/StatusItemController.swift
@@ -1,16 +1,27 @@
import AppKit
import SwiftUI
+private final class FirstMouseHostingView: NSHostingView {
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+}
+
@MainActor
final class StatusItemController: NSObject {
private let statusItem: NSStatusItem
private let popover: NSPopover
private let appController: AppController
+ private let onOpenSettings: () -> Void
- init(appController: AppController) {
+ init(
+ appController: AppController,
+ onOpenSettings: @escaping () -> Void
+ ) {
self.appController = appController
+ self.onOpenSettings = onOpenSettings
self.statusItem = NSStatusBar.system.statusItem(
- withLength: 31
+ withLength: NSStatusItem.squareLength
)
self.popover = NSPopover()
super.init()
@@ -40,9 +51,18 @@ final class StatusItemController: NSObject {
popover.behavior = .transient
popover.animates = true
popover.contentSize = NSSize(width: 340, height: 290)
- popover.contentViewController = NSHostingController(
- rootView: StatusPopoverView(appController: appController)
+ let hostingView = FirstMouseHostingView(
+ rootView: StatusPopoverView(
+ appController: appController,
+ onOpenSettings: { [weak self] in
+ self?.popover.performClose(nil)
+ self?.onOpenSettings()
+ }
+ )
)
+ let contentViewController = NSViewController()
+ contentViewController.view = hostingView
+ popover.contentViewController = contentViewController
}
private func updateStatusIcon() {
@@ -78,10 +98,10 @@ final class StatusItemController: NSObject {
return
}
- showPopover()
+ presentPopover()
}
- private func showPopover() {
+ func presentPopover() {
guard let button = statusItem.button else {
return
}
diff --git a/Sources/AgentAwake/StatusPopoverView.swift b/Sources/AgentAwake/StatusPopoverView.swift
index bcb95a9..aa4d700 100644
--- a/Sources/AgentAwake/StatusPopoverView.swift
+++ b/Sources/AgentAwake/StatusPopoverView.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct StatusPopoverView: View {
@ObservedObject var appController: AppController
+ let onOpenSettings: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -118,6 +119,13 @@ struct StatusPopoverView: View {
Spacer()
+ Button("设置") {
+ onOpenSettings()
+ }
+ .buttonStyle(.plain)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+
Button("退出") {
NSApplication.shared.terminate(nil)
}
@@ -174,9 +182,13 @@ struct StatusPopoverView: View {
)
.foregroundStyle(
appController.selectedMode == mode
- ? Color.primary
+ ? Color.accentColor
: Color.secondary
)
+ .animation(
+ .easeOut(duration: 0.16),
+ value: appController.selectedMode
+ )
}
private func labelAlignment(for mode: ProtectionMode) -> Alignment {
diff --git a/Sources/AgentAwakeHookSetup/main.swift b/Sources/AgentAwakeHookSetup/main.swift
index 92bbefb..8bb181d 100644
--- a/Sources/AgentAwakeHookSetup/main.swift
+++ b/Sources/AgentAwakeHookSetup/main.swift
@@ -1,50 +1,27 @@
+import AgentAwakeSetupCore
import Darwin
import Foundation
-private let adapterID = "com.raymond.agentawake"
-private let codexEvents = [
- "UserPromptSubmit",
- "PreToolUse",
- "PermissionRequest",
- "PostToolUse",
- "Stop",
- "SessionEnd"
-]
-private let claudeEvents = [
- "UserPromptSubmit",
- "PreToolUse",
- "PermissionRequest",
- "PostToolUse",
- "PostToolUseFailure",
- "Stop",
- "StopFailure",
- "SessionEnd"
-]
-
private enum SetupAction: String {
case install
case uninstall
+ case status
}
-private enum SetupError: LocalizedError {
+private enum SetupCommandError: LocalizedError {
case usage
- case helperMissing(String)
- case invalidJSONObject(String)
- case invalidHooks(String)
+ case invalidProvider(String)
var errorDescription: String? {
switch self {
case .usage:
return """
- 用法:AgentAwakeHookSetup install|uninstall \
- [--home 路径] [--helper AgentAwakeHook路径]
+ 用法:AgentAwakeHookSetup install|uninstall|status \
+ [--provider codex|claude] [--home 路径] \
+ [--helper AgentAwakeHook路径]
"""
- case let .helperMissing(path):
- return "找不到 AgentAwakeHook:\(path)"
- case let .invalidJSONObject(path):
- return "配置不是有效的 JSON 对象,未修改:\(path)"
- case let .invalidHooks(path):
- return "配置中的 hooks 结构无法安全合并,未修改:\(path)"
+ case let .invalidProvider(value):
+ return "不支持的 Agent:\(value)"
}
}
}
@@ -55,264 +32,29 @@ private func option(named name: String) -> String? {
else {
return nil
}
-
return CommandLine.arguments[index + 1]
}
-private func loadJSONObject(
- at url: URL,
- defaultValue: [String: Any]
-) throws -> [String: Any] {
- guard FileManager.default.fileExists(atPath: url.path) else {
- return defaultValue
- }
-
- let data = try Data(contentsOf: url)
- guard let object = try JSONSerialization.jsonObject(with: data)
- as? [String: Any]
- else {
- throw SetupError.invalidJSONObject(url.path)
- }
-
- return object
-}
-
-private func hookGroups(
- from value: Any?,
- configPath: String
-) throws -> [[String: Any]] {
- guard let value else {
- return []
- }
-
- guard let groups = value as? [[String: Any]] else {
- throw SetupError.invalidHooks(configPath)
- }
- return groups
-}
-
-private func isAgentAwakeHandler(_ handler: [String: Any]) -> Bool {
- if let command = handler["command"] as? String,
- command.contains("--adapter-id \(adapterID)")
- {
- return true
- }
-
- if let arguments = handler["args"] as? [String],
- let markerIndex = arguments.firstIndex(of: "--adapter-id"),
- arguments.indices.contains(markerIndex + 1),
- arguments[markerIndex + 1] == adapterID
- {
- return true
- }
-
- return false
-}
-
-private func removingAgentAwakeHandlers(
- from groups: [[String: Any]]
-) throws -> [[String: Any]] {
- try groups.compactMap { originalGroup in
- var group = originalGroup
- guard let handlersValue = group["hooks"] else {
- return group
- }
- guard let handlers = handlersValue as? [[String: Any]] else {
- throw SetupError.invalidHooks("hooks[].hooks")
- }
-
- let retained = handlers.filter { !isAgentAwakeHandler($0) }
- guard !retained.isEmpty else {
- return nil
- }
-
- group["hooks"] = retained
- return group
- }
-}
-
-private func singleQuotedShellArgument(_ value: String) -> String {
- "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
-}
-
-private func codexHandler(helperPath: String) -> [String: Any] {
- let command = [
- singleQuotedShellArgument(helperPath),
- "--provider codex",
- "--adapter-id \(adapterID)"
- ].joined(separator: " ")
-
- return [
- "type": "command",
- "command": command,
- "timeout": 3
- ]
-}
-
-private func claudeHandler(helperPath: String) -> [String: Any] {
- [
- "type": "command",
- "command": helperPath,
- "args": [
- "--provider",
- "claude",
- "--adapter-id",
- adapterID
- ],
- "timeout": 3
- ]
-}
-
-private func updateHooks(
- in root: inout [String: Any],
- events: [String],
- handler: [String: Any]?,
- configPath: String
-) throws {
- let existingHooks = root["hooks"] as? [String: Any]
- if root["hooks"] != nil, existingHooks == nil {
- throw SetupError.invalidHooks(configPath)
- }
-
- var hooks = existingHooks ?? [:]
-
- for event in events {
- let groups = try hookGroups(
- from: hooks[event],
- configPath: configPath
- )
- var updated = try removingAgentAwakeHandlers(from: groups)
-
- if let handler {
- updated.append(["hooks": [handler]])
- }
-
- if updated.isEmpty {
- hooks.removeValue(forKey: event)
- } else {
- hooks[event] = updated
- }
- }
-
- root["hooks"] = hooks
-}
-
-private func createBackupIfNeeded(at url: URL) throws {
- guard FileManager.default.fileExists(atPath: url.path) else {
- return
+private func stateDescription(_ state: AgentIntegrationState) -> String {
+ switch state {
+ case .notDetected:
+ return "未检测到"
+ case .available:
+ return "可选,尚未安装 Hooks"
+ case .installed:
+ return "Hooks 已安装"
+ case .needsRepair:
+ return "需要修复或更新"
+ case let .invalidConfiguration(message):
+ return "配置异常:\(message)"
}
-
- let backupURL = URL(fileURLWithPath: url.path + ".agentawake-backup")
- guard !FileManager.default.fileExists(atPath: backupURL.path) else {
- return
- }
-
- try FileManager.default.copyItem(at: url, to: backupURL)
-}
-
-private func writeJSONObject(
- _ object: [String: Any],
- to url: URL
-) throws {
- try FileManager.default.createDirectory(
- at: url.deletingLastPathComponent(),
- withIntermediateDirectories: true
- )
- try createBackupIfNeeded(at: url)
-
- var data = try JSONSerialization.data(
- withJSONObject: object,
- options: [.prettyPrinted, .sortedKeys]
- )
- data.append(0x0A)
- try data.write(to: url, options: .atomic)
- try? FileManager.default.setAttributes(
- [.posixPermissions: 0o600],
- ofItemAtPath: url.path
- )
-}
-
-private func installStableHelper(
- sourceURL: URL,
- homeDirectory: URL
-) throws -> URL {
- guard FileManager.default.isExecutableFile(atPath: sourceURL.path) else {
- throw SetupError.helperMissing(sourceURL.path)
- }
-
- let destinationDirectory = homeDirectory
- .appendingPathComponent("Library", isDirectory: true)
- .appendingPathComponent("Application Support", isDirectory: true)
- .appendingPathComponent("AgentAwake", isDirectory: true)
- .appendingPathComponent("bin", isDirectory: true)
- let destinationURL = destinationDirectory
- .appendingPathComponent("AgentAwakeHook")
-
- try FileManager.default.createDirectory(
- at: destinationDirectory,
- withIntermediateDirectories: true
- )
-
- if sourceURL.standardizedFileURL != destinationURL.standardizedFileURL {
- let data = try Data(contentsOf: sourceURL)
- try data.write(to: destinationURL, options: .atomic)
- }
-
- try FileManager.default.setAttributes(
- [.posixPermissions: 0o755],
- ofItemAtPath: destinationURL.path
- )
- return destinationURL
-}
-
-private func configureCodex(
- action: SetupAction,
- helperPath: String?,
- homeDirectory: URL
-) throws {
- let url = homeDirectory
- .appendingPathComponent(".codex", isDirectory: true)
- .appendingPathComponent("hooks.json")
- var root = try loadJSONObject(
- at: url,
- defaultValue: [
- "description": "Personal lifecycle hooks."
- ]
- )
-
- try updateHooks(
- in: &root,
- events: codexEvents,
- handler: helperPath.map(codexHandler),
- configPath: url.path
- )
- try writeJSONObject(root, to: url)
-}
-
-private func configureClaude(
- action: SetupAction,
- helperPath: String?,
- homeDirectory: URL
-) throws {
- let url = homeDirectory
- .appendingPathComponent(".claude", isDirectory: true)
- .appendingPathComponent("settings.json")
- var root = try loadJSONObject(at: url, defaultValue: [:])
-
- try updateHooks(
- in: &root,
- events: claudeEvents,
- handler: helperPath.map(claudeHandler),
- configPath: url.path
- )
- try writeJSONObject(root, to: url)
}
do {
guard CommandLine.arguments.count >= 2,
let action = SetupAction(rawValue: CommandLine.arguments[1])
else {
- throw SetupError.usage
+ throw SetupCommandError.usage
}
let homeDirectory = option(named: "--home").map {
@@ -322,40 +64,60 @@ do {
let executableURL = URL(
fileURLWithPath: CommandLine.arguments[0]
).standardizedFileURL
- let sourceHelperURL = option(named: "--helper").map {
+ let helperURL = option(named: "--helper").map {
URL(fileURLWithPath: $0)
} ?? executableURL
.deletingLastPathComponent()
.appendingPathComponent("AgentAwakeHook")
- let installedHelperURL: URL?
- switch action {
- case .install:
- installedHelperURL = try installStableHelper(
- sourceURL: sourceHelperURL,
- homeDirectory: homeDirectory
- )
- case .uninstall:
- installedHelperURL = nil
- }
-
- try configureCodex(
- action: action,
- helperPath: installedHelperURL?.path,
- homeDirectory: homeDirectory
- )
- try configureClaude(
- action: action,
- helperPath: installedHelperURL?.path,
- homeDirectory: homeDirectory
+ let manager = AgentIntegrationManager(
+ homeDirectory: homeDirectory,
+ bundledHelperURL: helperURL
)
+ let providers: [AgentIntegrationProvider]
+ if let providerName = option(named: "--provider") {
+ guard let provider = AgentIntegrationProvider(
+ rawValue: providerName.lowercased()
+ ) else {
+ throw SetupCommandError.invalidProvider(providerName)
+ }
+ providers = [provider]
+ } else {
+ providers = manager.detectedProviders()
+ }
+
switch action {
case .install:
- print("AgentAwake Hooks 已写入 Codex 与 Claude 配置。")
- print("Codex 还需在 /hooks 中审核并信任新增命令。")
+ guard !providers.isEmpty else {
+ print("未检测到 Codex 或 Claude;未创建任何配置。")
+ exit(EXIT_SUCCESS)
+ }
+ for provider in providers {
+ try manager.install(provider)
+ print("\(provider.displayName) Hooks 已安装。")
+ }
+ if providers.contains(.codex) {
+ print("Codex 还需在 /hooks 中审核并信任新增命令。")
+ }
+
case .uninstall:
- print("AgentAwake Hooks 已从 Codex 与 Claude 配置移除。")
+ for provider in providers {
+ try manager.uninstall(provider)
+ print("\(provider.displayName) Hooks 已移除。")
+ }
+
+ case .status:
+ let snapshots = manager.inspectAll().filter { snapshot in
+ option(named: "--provider") == nil
+ || providers.contains(snapshot.provider)
+ }
+ for snapshot in snapshots {
+ print(
+ "\(snapshot.provider.displayName):" +
+ stateDescription(snapshot.state)
+ )
+ }
}
} catch {
fputs("AgentAwake Hook Setup: \(error.localizedDescription)\n", stderr)
diff --git a/Sources/AgentAwakeSelfTest/main.swift b/Sources/AgentAwakeSelfTest/main.swift
index fd2ac1d..faa549b 100644
--- a/Sources/AgentAwakeSelfTest/main.swift
+++ b/Sources/AgentAwakeSelfTest/main.swift
@@ -1,4 +1,5 @@
import AgentAwakeCore
+import AgentAwakeSetupCore
import Darwin
import Foundation
@@ -388,10 +389,180 @@ private func testAgentActivityParsing() {
)
}
+private func testSelectiveIntegrationSetup() {
+ let fileManager = FileManager.default
+ let root = fileManager.temporaryDirectory
+ .appendingPathComponent(
+ "AgentAwakeSetupTest-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer {
+ try? fileManager.removeItem(at: root)
+ }
+
+ let codexDirectory = root.appendingPathComponent(
+ ".codex",
+ isDirectory: true
+ )
+ let sessionsDirectory = codexDirectory.appendingPathComponent(
+ "sessions",
+ isDirectory: true
+ )
+ try? fileManager.createDirectory(
+ at: sessionsDirectory,
+ withIntermediateDirectories: true
+ )
+
+ let configURL = codexDirectory.appendingPathComponent("hooks.json")
+ let originalConfig: [String: Any] = [
+ "description": "Existing user hooks",
+ "hooks": [
+ "UserPromptSubmit": [
+ [
+ "hooks": [
+ [
+ "type": "command",
+ "command": "/usr/bin/true",
+ "timeout": 2
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ if let data = try? JSONSerialization.data(
+ withJSONObject: originalConfig,
+ options: [.prettyPrinted, .sortedKeys]
+ ) {
+ try? data.write(to: configURL)
+ }
+
+ let helperURL = root.appendingPathComponent("AgentAwakeHook")
+ try? Data("agentawake-test-helper".utf8).write(to: helperURL)
+ try? fileManager.setAttributes(
+ [.posixPermissions: 0o755],
+ ofItemAtPath: helperURL.path
+ )
+
+ let manager = AgentIntegrationManager(
+ homeDirectory: root,
+ bundledHelperURL: helperURL
+ )
+ let initial = manager.inspect(.codex)
+ expect(initial.state == .available, "检测到 Codex 后 Hooks 应保持可选")
+ expect(initial.hasLocalActivityData, "应识别已有 Codex 活动目录")
+
+ do {
+ try manager.install(.codex)
+ } catch {
+ failures.append("Codex Hooks 安装失败:\(error.localizedDescription)")
+ }
+
+ expect(
+ manager.inspect(.codex).state == .installed,
+ "安装后 Codex Hooks 状态应为已安装"
+ )
+ expect(
+ !fileManager.fileExists(
+ atPath: root
+ .appendingPathComponent(".claude/settings.json")
+ .path
+ ),
+ "安装 Codex 集成时不应创建 Claude 配置"
+ )
+ expect(
+ fileManager.fileExists(
+ atPath: configURL.path + ".agentawake-backup"
+ ),
+ "首次修改已有配置前应创建备份"
+ )
+
+ let installedConfig = (try? String(contentsOf: configURL)) ?? ""
+ expect(
+ installedConfig.contains("usr") && installedConfig.contains("true"),
+ "安装 Hooks 时必须保留用户原有命令"
+ )
+ expect(
+ installedConfig.contains(AgentIntegrationManager.adapterID),
+ "安装后配置应包含 AgentAwake 标识"
+ )
+
+ do {
+ try manager.install(.codex)
+ let repeatedConfig = (try? String(contentsOf: configURL)) ?? ""
+ let adapterCount = repeatedConfig.components(
+ separatedBy: AgentIntegrationManager.adapterID
+ ).count - 1
+ expect(adapterCount == 6, "重复安装不应追加重复的 Codex Hooks")
+ try manager.uninstall(.codex)
+ } catch {
+ failures.append("Codex Hooks 更新或移除失败:\(error.localizedDescription)")
+ }
+
+ let uninstalledConfig = (try? String(contentsOf: configURL)) ?? ""
+ expect(
+ uninstalledConfig.contains("usr") && uninstalledConfig.contains("true"),
+ "移除 Hooks 时必须保留用户原有命令"
+ )
+ expect(
+ !uninstalledConfig.contains(AgentIntegrationManager.adapterID),
+ "移除后不应残留 AgentAwake Hook"
+ )
+ expect(
+ !fileManager.fileExists(atPath: manager.installedHelperURL.path),
+ "没有集成使用 helper 时应移除稳定副本"
+ )
+
+ do {
+ try manager.install(.claude)
+ failures.append("未检测到 Claude 时不应创建配置")
+ } catch {
+ expect(
+ manager.inspect(.claude).state == .notDetected,
+ "未检测到 Claude 时状态应保持未检测到"
+ )
+ }
+
+ let claudeDirectory = root.appendingPathComponent(
+ ".claude",
+ isDirectory: true
+ )
+ try? fileManager.createDirectory(
+ at: claudeDirectory,
+ withIntermediateDirectories: true
+ )
+ let invalidClaudeConfig = claudeDirectory.appendingPathComponent(
+ "settings.json"
+ )
+ let invalidData = Data(#"{"hooks":"not-an-object"}"#.utf8)
+ try? invalidData.write(to: invalidClaudeConfig)
+
+ if case .invalidConfiguration = manager.inspect(.claude).state {
+ expect(true, "异常 Claude 配置应被识别")
+ } else {
+ failures.append("异常 Claude 配置应显示配置错误")
+ }
+
+ do {
+ try manager.install(.claude)
+ failures.append("异常 Claude 配置不应被覆盖")
+ } catch {
+ expect(
+ (try? Data(contentsOf: invalidClaudeConfig)) == invalidData,
+ "安装失败后必须保持异常配置原文不变"
+ )
+ expect(
+ !fileManager.fileExists(atPath: manager.installedHelperURL.path),
+ "配置异常时不应留下未使用的 helper"
+ )
+ }
+}
+
testProtectionSession()
testAgentActivityParsing()
testHookActivityStore()
testHookOverridesTranscriptFallback()
+testSelectiveIntegrationSetup()
if failures.isEmpty {
let detector = SystemAgentDetector()
diff --git a/Sources/AgentAwakeSetupCore/AgentIntegrationManager.swift b/Sources/AgentAwakeSetupCore/AgentIntegrationManager.swift
new file mode 100644
index 0000000..73a4ba6
--- /dev/null
+++ b/Sources/AgentAwakeSetupCore/AgentIntegrationManager.swift
@@ -0,0 +1,572 @@
+import Foundation
+
+public enum AgentIntegrationProvider: String, CaseIterable, Identifiable, Sendable {
+ case codex
+ case claude
+
+ public var id: String { rawValue }
+
+ public var displayName: String {
+ switch self {
+ case .codex:
+ return "Codex"
+ case .claude:
+ return "Claude"
+ }
+ }
+
+ fileprivate var homeDirectoryName: String {
+ ".\(rawValue)"
+ }
+
+ fileprivate var configurationFilename: String {
+ switch self {
+ case .codex:
+ return "hooks.json"
+ case .claude:
+ return "settings.json"
+ }
+ }
+
+ fileprivate var activityDirectoryComponents: [String] {
+ switch self {
+ case .codex:
+ return ["sessions"]
+ case .claude:
+ return ["projects"]
+ }
+ }
+
+ fileprivate var events: [String] {
+ switch self {
+ case .codex:
+ return [
+ "UserPromptSubmit",
+ "PreToolUse",
+ "PermissionRequest",
+ "PostToolUse",
+ "Stop",
+ "SessionEnd"
+ ]
+ case .claude:
+ return [
+ "UserPromptSubmit",
+ "PreToolUse",
+ "PermissionRequest",
+ "PostToolUse",
+ "PostToolUseFailure",
+ "Stop",
+ "StopFailure",
+ "SessionEnd"
+ ]
+ }
+ }
+}
+
+public enum AgentIntegrationState: Equatable, Sendable {
+ case notDetected
+ case available
+ case installed
+ case needsRepair
+ case invalidConfiguration(String)
+}
+
+public struct AgentIntegrationSnapshot: Identifiable, Equatable, Sendable {
+ public let provider: AgentIntegrationProvider
+ public let state: AgentIntegrationState
+ public let hasLocalActivityData: Bool
+ public let configurationPath: String
+
+ public var id: String { provider.id }
+
+ public init(
+ provider: AgentIntegrationProvider,
+ state: AgentIntegrationState,
+ hasLocalActivityData: Bool,
+ configurationPath: String
+ ) {
+ self.provider = provider
+ self.state = state
+ self.hasLocalActivityData = hasLocalActivityData
+ self.configurationPath = configurationPath
+ }
+}
+
+public enum AgentIntegrationError: LocalizedError {
+ case providerNotDetected(String)
+ case helperMissing(String)
+ case invalidJSONObject(String)
+ case invalidHooks(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .providerNotDetected(provider):
+ return "尚未检测到 \(provider),未创建新的配置目录。"
+ case let .helperMissing(path):
+ return "找不到 AgentAwakeHook:\(path)"
+ case let .invalidJSONObject(path):
+ return "配置不是有效的 JSON 对象,未修改:\(path)"
+ case let .invalidHooks(path):
+ return "配置中的 hooks 结构无法安全合并,未修改:\(path)"
+ }
+ }
+}
+
+public final class AgentIntegrationManager: @unchecked Sendable {
+ public static let adapterID = "com.raymond.agentawake"
+
+ public let homeDirectory: URL
+ public let bundledHelperURL: URL
+
+ private let fileManager: FileManager
+ private let lock = NSLock()
+
+ public init(
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
+ bundledHelperURL: URL,
+ fileManager: FileManager = .default
+ ) {
+ self.homeDirectory = homeDirectory
+ self.bundledHelperURL = bundledHelperURL
+ self.fileManager = fileManager
+ }
+
+ public var installedHelperURL: URL {
+ homeDirectory
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("AgentAwake", isDirectory: true)
+ .appendingPathComponent("bin", isDirectory: true)
+ .appendingPathComponent("AgentAwakeHook")
+ }
+
+ public func detectedProviders() -> [AgentIntegrationProvider] {
+ AgentIntegrationProvider.allCases.filter(isDetected)
+ }
+
+ public func inspectAll() -> [AgentIntegrationSnapshot] {
+ AgentIntegrationProvider.allCases.map(inspect)
+ }
+
+ public func inspect(
+ _ provider: AgentIntegrationProvider
+ ) -> AgentIntegrationSnapshot {
+ lock.lock()
+ defer { lock.unlock() }
+ return inspectUnlocked(provider)
+ }
+
+ public func install(_ provider: AgentIntegrationProvider) throws {
+ lock.lock()
+ defer { lock.unlock() }
+
+ guard isDetected(provider) else {
+ throw AgentIntegrationError.providerNotDetected(
+ provider.displayName
+ )
+ }
+
+ let configURL = configurationURL(for: provider)
+ var root = try loadJSONObject(
+ at: configURL,
+ defaultValue: provider == .codex
+ ? ["description": "Personal lifecycle hooks."]
+ : [:]
+ )
+ try updateHooks(
+ in: &root,
+ provider: provider,
+ handler: handler(
+ for: provider,
+ helperPath: installedHelperURL.path
+ )
+ )
+
+ do {
+ _ = try installStableHelper()
+ try writeJSONObject(root, to: configURL)
+ } catch {
+ if !hasAnyConfiguredIntegrationUnlocked() {
+ try? fileManager.removeItem(at: installedHelperURL)
+ }
+ throw error
+ }
+ }
+
+ public func uninstall(_ provider: AgentIntegrationProvider) throws {
+ lock.lock()
+ defer { lock.unlock() }
+
+ let configURL = configurationURL(for: provider)
+ if fileManager.fileExists(atPath: configURL.path) {
+ var root = try loadJSONObject(at: configURL, defaultValue: [:])
+ let isConfigured = try hasAgentAwakeHandler(
+ in: root,
+ provider: provider,
+ configPath: configURL.path
+ )
+ if isConfigured {
+ try updateHooks(in: &root, provider: provider, handler: nil)
+ try writeJSONObject(root, to: configURL)
+ }
+ }
+
+ if !hasAnyConfiguredIntegrationUnlocked() {
+ try? fileManager.removeItem(at: installedHelperURL)
+ }
+ }
+
+ public func configurationURL(
+ for provider: AgentIntegrationProvider
+ ) -> URL {
+ providerHomeURL(provider)
+ .appendingPathComponent(provider.configurationFilename)
+ }
+
+ private func inspectUnlocked(
+ _ provider: AgentIntegrationProvider
+ ) -> AgentIntegrationSnapshot {
+ let configURL = configurationURL(for: provider)
+ let hasLocalActivityData = fileManager.fileExists(
+ atPath: activityDirectoryURL(provider).path
+ )
+
+ guard isDetected(provider) else {
+ return AgentIntegrationSnapshot(
+ provider: provider,
+ state: .notDetected,
+ hasLocalActivityData: false,
+ configurationPath: configURL.path
+ )
+ }
+
+ guard fileManager.fileExists(atPath: configURL.path) else {
+ return AgentIntegrationSnapshot(
+ provider: provider,
+ state: .available,
+ hasLocalActivityData: hasLocalActivityData,
+ configurationPath: configURL.path
+ )
+ }
+
+ do {
+ let root = try loadJSONObject(at: configURL, defaultValue: [:])
+ let configured = try hasAgentAwakeHandler(
+ in: root,
+ provider: provider,
+ configPath: configURL.path
+ )
+ guard configured else {
+ return AgentIntegrationSnapshot(
+ provider: provider,
+ state: .available,
+ hasLocalActivityData: hasLocalActivityData,
+ configurationPath: configURL.path
+ )
+ }
+
+ let helperIsCurrent = helperMatchesBundledVersion()
+ return AgentIntegrationSnapshot(
+ provider: provider,
+ state: helperIsCurrent ? .installed : .needsRepair,
+ hasLocalActivityData: hasLocalActivityData,
+ configurationPath: configURL.path
+ )
+ } catch {
+ return AgentIntegrationSnapshot(
+ provider: provider,
+ state: .invalidConfiguration(error.localizedDescription),
+ hasLocalActivityData: hasLocalActivityData,
+ configurationPath: configURL.path
+ )
+ }
+ }
+
+ private func isDetected(_ provider: AgentIntegrationProvider) -> Bool {
+ fileManager.fileExists(atPath: providerHomeURL(provider).path)
+ }
+
+ private func providerHomeURL(
+ _ provider: AgentIntegrationProvider
+ ) -> URL {
+ homeDirectory.appendingPathComponent(
+ provider.homeDirectoryName,
+ isDirectory: true
+ )
+ }
+
+ private func activityDirectoryURL(
+ _ provider: AgentIntegrationProvider
+ ) -> URL {
+ provider.activityDirectoryComponents.reduce(
+ providerHomeURL(provider)
+ ) { partialURL, component in
+ partialURL.appendingPathComponent(component, isDirectory: true)
+ }
+ }
+
+ private func helperMatchesBundledVersion() -> Bool {
+ guard fileManager.isExecutableFile(atPath: installedHelperURL.path),
+ let bundledData = try? Data(contentsOf: bundledHelperURL),
+ let installedData = try? Data(contentsOf: installedHelperURL)
+ else {
+ return false
+ }
+
+ return bundledData == installedData
+ }
+
+ private func installStableHelper() throws -> URL {
+ guard fileManager.isExecutableFile(atPath: bundledHelperURL.path) else {
+ throw AgentIntegrationError.helperMissing(bundledHelperURL.path)
+ }
+
+ let destinationURL = installedHelperURL
+ try fileManager.createDirectory(
+ at: destinationURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+
+ if bundledHelperURL.standardizedFileURL
+ != destinationURL.standardizedFileURL
+ {
+ let data = try Data(contentsOf: bundledHelperURL)
+ try data.write(to: destinationURL, options: .atomic)
+ }
+
+ try fileManager.setAttributes(
+ [.posixPermissions: 0o755],
+ ofItemAtPath: destinationURL.path
+ )
+ return destinationURL
+ }
+
+ private func loadJSONObject(
+ at url: URL,
+ defaultValue: [String: Any]
+ ) throws -> [String: Any] {
+ guard fileManager.fileExists(atPath: url.path) else {
+ return defaultValue
+ }
+
+ let data = try Data(contentsOf: url)
+ guard let object = try JSONSerialization.jsonObject(with: data)
+ as? [String: Any]
+ else {
+ throw AgentIntegrationError.invalidJSONObject(url.path)
+ }
+
+ return object
+ }
+
+ private func hookGroups(
+ from value: Any?,
+ configPath: String
+ ) throws -> [[String: Any]] {
+ guard let value else {
+ return []
+ }
+ guard let groups = value as? [[String: Any]] else {
+ throw AgentIntegrationError.invalidHooks(configPath)
+ }
+ return groups
+ }
+
+ private func isAgentAwakeHandler(_ handler: [String: Any]) -> Bool {
+ if let command = handler["command"] as? String,
+ command.contains("--adapter-id \(Self.adapterID)")
+ {
+ return true
+ }
+
+ if let arguments = handler["args"] as? [String],
+ let markerIndex = arguments.firstIndex(of: "--adapter-id"),
+ arguments.indices.contains(markerIndex + 1),
+ arguments[markerIndex + 1] == Self.adapterID
+ {
+ return true
+ }
+
+ return false
+ }
+
+ private func removingAgentAwakeHandlers(
+ from groups: [[String: Any]],
+ configPath: String
+ ) throws -> [[String: Any]] {
+ try groups.compactMap { originalGroup in
+ var group = originalGroup
+ guard let handlersValue = group["hooks"] else {
+ return group
+ }
+ guard let handlers = handlersValue as? [[String: Any]] else {
+ throw AgentIntegrationError.invalidHooks(configPath)
+ }
+
+ let retained = handlers.filter { !isAgentAwakeHandler($0) }
+ guard !retained.isEmpty else {
+ return nil
+ }
+
+ group["hooks"] = retained
+ return group
+ }
+ }
+
+ private func hasAgentAwakeHandler(
+ in root: [String: Any],
+ provider: AgentIntegrationProvider,
+ configPath: String
+ ) throws -> Bool {
+ guard let hooksValue = root["hooks"] else {
+ return false
+ }
+ guard let hooks = hooksValue as? [String: Any] else {
+ throw AgentIntegrationError.invalidHooks(configPath)
+ }
+
+ for event in provider.events {
+ let groups = try hookGroups(
+ from: hooks[event],
+ configPath: configPath
+ )
+ for group in groups {
+ guard let handlersValue = group["hooks"] else {
+ continue
+ }
+ guard let handlers = handlersValue as? [[String: Any]] else {
+ throw AgentIntegrationError.invalidHooks(configPath)
+ }
+ if handlers.contains(where: isAgentAwakeHandler) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
+ private func updateHooks(
+ in root: inout [String: Any],
+ provider: AgentIntegrationProvider,
+ handler: [String: Any]?
+ ) throws {
+ let configPath = configurationURL(for: provider).path
+ let existingHooks = root["hooks"] as? [String: Any]
+ if root["hooks"] != nil, existingHooks == nil {
+ throw AgentIntegrationError.invalidHooks(configPath)
+ }
+
+ var hooks = existingHooks ?? [:]
+ for event in provider.events {
+ let groups = try hookGroups(
+ from: hooks[event],
+ configPath: configPath
+ )
+ var updated = try removingAgentAwakeHandlers(
+ from: groups,
+ configPath: configPath
+ )
+
+ if let handler {
+ updated.append(["hooks": [handler]])
+ }
+
+ if updated.isEmpty {
+ hooks.removeValue(forKey: event)
+ } else {
+ hooks[event] = updated
+ }
+ }
+ root["hooks"] = hooks
+ }
+
+ private func createBackupIfNeeded(at url: URL) throws {
+ guard fileManager.fileExists(atPath: url.path) else {
+ return
+ }
+
+ let backupURL = URL(fileURLWithPath: url.path + ".agentawake-backup")
+ guard !fileManager.fileExists(atPath: backupURL.path) else {
+ return
+ }
+ try fileManager.copyItem(at: url, to: backupURL)
+ }
+
+ private func writeJSONObject(
+ _ object: [String: Any],
+ to url: URL
+ ) throws {
+ try fileManager.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try createBackupIfNeeded(at: url)
+
+ var data = try JSONSerialization.data(
+ withJSONObject: object,
+ options: [.prettyPrinted, .sortedKeys]
+ )
+ data.append(0x0A)
+ try data.write(to: url, options: .atomic)
+ try? fileManager.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: url.path
+ )
+ }
+
+ private func handler(
+ for provider: AgentIntegrationProvider,
+ helperPath: String
+ ) -> [String: Any] {
+ switch provider {
+ case .codex:
+ let command = [
+ singleQuotedShellArgument(helperPath),
+ "--provider codex",
+ "--adapter-id \(Self.adapterID)"
+ ].joined(separator: " ")
+ return [
+ "type": "command",
+ "command": command,
+ "timeout": 3
+ ]
+
+ case .claude:
+ return [
+ "type": "command",
+ "command": helperPath,
+ "args": [
+ "--provider",
+ "claude",
+ "--adapter-id",
+ Self.adapterID
+ ],
+ "timeout": 3
+ ]
+ }
+ }
+
+ private func singleQuotedShellArgument(_ value: String) -> String {
+ "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+
+ private func hasAnyConfiguredIntegrationUnlocked() -> Bool {
+ AgentIntegrationProvider.allCases.contains { provider in
+ let configURL = configurationURL(for: provider)
+ guard fileManager.fileExists(atPath: configURL.path),
+ let root = try? loadJSONObject(
+ at: configURL,
+ defaultValue: [:]
+ )
+ else {
+ return false
+ }
+ return (try? hasAgentAwakeHandler(
+ in: root,
+ provider: provider,
+ configPath: configURL.path
+ )) == true
+ }
+ }
+}
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..b793639
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,52 @@
+# AgentAwake 发布说明
+
+AgentAwake 当前采用无 Apple 付费凭据的快速公开发布路线:主程序和两个 helper
+使用 ad-hoc 签名,GitHub Release 分别提供 Apple Silicon 与 Intel DMG。
+
+这条路线不需要 Apple Developer Program、Developer ID、Team ID、公证密码或
+GitHub Actions Secrets。代价是用户首次打开时可能需要在“系统设置 → 隐私与安全性”
+中点击一次“仍要打开”;README 必须始终保留这一步的明确说明。
+
+## 本地验证
+
+```bash
+swift run --disable-sandbox AgentAwakeSelfTest
+./scripts/build-app.sh release arm64
+codesign --verify --deep --strict --verbose=2 dist/AgentAwake.app
+file dist/AgentAwake.app/Contents/MacOS/AgentAwake
+```
+
+本地和公开版本都使用 ad-hoc 签名。该签名可验证 App 内容在打包后没有损坏,
+但不会建立 Apple 开发者身份,也不会获得 Apple 公证票据。
+
+## 正式打包
+
+```bash
+./scripts/package-release.sh
+```
+
+脚本会分别交叉编译 `arm64` 与 `x86_64`,验证主程序及两个 helper 的实际架构,
+进行 ad-hoc 签名,制作并验证两个 DMG,最后生成一个 SHA-256 校验文件:
+
+- `AgentAwake-x.y.z-Apple-Silicon.dmg`
+- `AgentAwake-x.y.z-Intel.dmg`
+- `AgentAwake-x.y.z.sha256`
+
+## GitHub Release
+
+1. 确认 `Resources/Info.plist`、README 与 Changelog 版本一致。
+2. 确认 `main` 的 CI 通过。
+3. 创建与版本一致的 Tag,例如 `v0.4.0`。
+4. 推送 Tag 后,`.github/workflows/release.yml` 无需 Secrets 即可自动发布两个
+ DMG 和校验文件。
+5. 在一台干净的 Mac 上从 GitHub Release 真实下载并完成最终验收。
+
+## 最终验收
+
+- 用户不需要 Xcode、Swift、Codex、Claude 或 Cloud;
+- README 已写明 Gatekeeper 首次放行步骤,且不要求终端命令;
+- Apple Silicon DMG 的主程序及两个 helper 仅包含 `arm64`;
+- Intel DMG 的主程序及两个 helper 仅包含 `x86_64`;
+- 两个 App 均通过严格的 ad-hoc 代码签名验证,两个 DMG 均通过 `hdiutil verify`;
+- 不安装 Hooks 时,定时模式和本地日志兜底仍可使用;
+- Hooks 只能由用户在设置中明确选择安装或移除。
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
index f232ce3..d355f81 100755
--- a/scripts/build-app.sh
+++ b/scripts/build-app.sh
@@ -4,46 +4,118 @@ set -euo pipefail
PROJECT_DIR="${0:A:h:h}"
CONFIGURATION="${1:-release}"
+ARCHITECTURE_MODE="${2:-universal}"
APP_DIR="$PROJECT_DIR/dist/AgentAwake.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
HELPERS_DIR="$CONTENTS_DIR/Helpers"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
APP_ICON="$PROJECT_DIR/Resources/AppIcon.icns"
+BUILD_ROOT="$PROJECT_DIR/.build/app-bundle"
+EXECUTABLES=(AgentAwake AgentAwakeHook AgentAwakeHookSetup)
export CLANG_MODULE_CACHE_PATH="/private/tmp/agentawake-clang-cache"
export SWIFT_MODULECACHE_PATH="/private/tmp/agentawake-swift-cache"
+if [[ "$CONFIGURATION" != "debug" && "$CONFIGURATION" != "release" ]]; then
+ echo "Configuration must be debug or release." >&2
+ exit 64
+fi
+
+case "$ARCHITECTURE_MODE" in
+ native|universal|arm64|x86_64)
+ ;;
+ *)
+ echo "Architecture mode must be native, universal, arm64, or x86_64." >&2
+ exit 64
+ ;;
+esac
+
if [[ ! -f "$APP_ICON" ]]; then
"$PROJECT_DIR/scripts/build-icon.sh"
fi
-/usr/bin/swift build \
- --package-path "$PROJECT_DIR" \
- --configuration "$CONFIGURATION" \
- --disable-sandbox
+build_for_architecture() {
+ local architecture="$1"
+ local triple="${architecture}-apple-macosx13.0"
+ local scratch_path="$BUILD_ROOT/$architecture"
+
+ /usr/bin/swift build \
+ --package-path "$PROJECT_DIR" \
+ --configuration "$CONFIGURATION" \
+ --triple "$triple" \
+ --scratch-path "$scratch_path" \
+ --disable-sandbox >&2
+ /usr/bin/swift build \
+ --package-path "$PROJECT_DIR" \
+ --configuration "$CONFIGURATION" \
+ --triple "$triple" \
+ --scratch-path "$scratch_path" \
+ --disable-sandbox \
+ --show-bin-path
+}
+
+copy_executables_from_directory() {
+ local source_directory="$1"
+
+ /bin/cp "$source_directory/AgentAwake" "$MACOS_DIR/AgentAwake"
+ /bin/cp \
+ "$source_directory/AgentAwakeHook" \
+ "$HELPERS_DIR/AgentAwakeHook"
+ /bin/cp \
+ "$source_directory/AgentAwakeHookSetup" \
+ "$HELPERS_DIR/AgentAwakeHookSetup"
+}
+
+/bin/rm -rf "$APP_DIR"
/bin/mkdir -p "$MACOS_DIR" "$HELPERS_DIR" "$RESOURCES_DIR"
-/bin/cp \
- "$PROJECT_DIR/.build/$CONFIGURATION/AgentAwake" \
- "$MACOS_DIR/AgentAwake"
+
+if [[ "$ARCHITECTURE_MODE" == "universal" ]]; then
+ ARM64_BIN_DIR="$(build_for_architecture arm64)"
+ X86_64_BIN_DIR="$(build_for_architecture x86_64)"
+
+ for executable in $EXECUTABLES; do
+ destination="$MACOS_DIR/$executable"
+ if [[ "$executable" != "AgentAwake" ]]; then
+ destination="$HELPERS_DIR/$executable"
+ fi
+
+ /usr/bin/lipo -create \
+ "$ARM64_BIN_DIR/$executable" \
+ "$X86_64_BIN_DIR/$executable" \
+ -output "$destination"
+ done
+elif [[ "$ARCHITECTURE_MODE" == "arm64" \
+ || "$ARCHITECTURE_MODE" == "x86_64" ]]; then
+ ARCHITECTURE_BIN_DIR="$(build_for_architecture "$ARCHITECTURE_MODE")"
+ copy_executables_from_directory "$ARCHITECTURE_BIN_DIR"
+else
+ /usr/bin/swift build \
+ --package-path "$PROJECT_DIR" \
+ --configuration "$CONFIGURATION" \
+ --disable-sandbox
+ NATIVE_BIN_DIR="$PROJECT_DIR/.build/$CONFIGURATION"
+ copy_executables_from_directory "$NATIVE_BIN_DIR"
+fi
+
/bin/cp "$PROJECT_DIR/Resources/Info.plist" "$CONTENTS_DIR/Info.plist"
/bin/cp "$APP_ICON" "$RESOURCES_DIR/AppIcon.icns"
-/bin/chmod 755 "$MACOS_DIR/AgentAwake"
-/bin/cp \
- "$PROJECT_DIR/.build/$CONFIGURATION/AgentAwakeHook" \
- "$HELPERS_DIR/AgentAwakeHook"
-/bin/cp \
- "$PROJECT_DIR/.build/$CONFIGURATION/AgentAwakeHookSetup" \
- "$HELPERS_DIR/AgentAwakeHookSetup"
/bin/chmod 755 \
+ "$MACOS_DIR/AgentAwake" \
"$HELPERS_DIR/AgentAwakeHook" \
"$HELPERS_DIR/AgentAwakeHookSetup"
+for helper in "$HELPERS_DIR/AgentAwakeHook" \
+ "$HELPERS_DIR/AgentAwakeHookSetup"; do
+ /usr/bin/codesign --force --sign - "$helper"
+done
+
/usr/bin/codesign \
--force \
- --deep \
--sign - \
"$APP_DIR"
+/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_DIR"
+
echo "$APP_DIR"
diff --git a/scripts/package-release.sh b/scripts/package-release.sh
new file mode 100755
index 0000000..c875ef8
--- /dev/null
+++ b/scripts/package-release.sh
@@ -0,0 +1,68 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+PROJECT_DIR="${0:A:h:h}"
+APP_DIR="$PROJECT_DIR/dist/AgentAwake.app"
+RELEASE_DIR="$PROJECT_DIR/dist/release"
+STAGING_ROOT="$PROJECT_DIR/dist/dmg-staging"
+INFO_PLIST="$PROJECT_DIR/Resources/Info.plist"
+VERSION="$(/usr/libexec/PlistBuddy \
+ -c 'Print :CFBundleShortVersionString' \
+ "$INFO_PLIST")"
+APPLE_SILICON_DMG="$RELEASE_DIR/AgentAwake-$VERSION-Apple-Silicon.dmg"
+INTEL_DMG="$RELEASE_DIR/AgentAwake-$VERSION-Intel.dmg"
+CHECKSUM_PATH="$RELEASE_DIR/AgentAwake-$VERSION.sha256"
+
+package_architecture() {
+ local architecture="$1"
+ local dmg_path="$2"
+ local staging_directory="$STAGING_ROOT/$architecture"
+
+ "$PROJECT_DIR/scripts/build-app.sh" release "$architecture"
+ /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_DIR"
+
+ for executable in \
+ "$APP_DIR/Contents/MacOS/AgentAwake" \
+ "$APP_DIR/Contents/Helpers/AgentAwakeHook" \
+ "$APP_DIR/Contents/Helpers/AgentAwakeHookSetup"; do
+ actual_architecture="$(/usr/bin/lipo -archs "$executable")"
+ if [[ "$actual_architecture" != "$architecture" ]]; then
+ echo "Expected $architecture but found $actual_architecture: $executable" >&2
+ exit 65
+ fi
+ done
+
+ /bin/rm -rf "$staging_directory"
+ /bin/mkdir -p "$staging_directory"
+ /usr/bin/ditto "$APP_DIR" "$staging_directory/AgentAwake.app"
+ /bin/ln -s /Applications "$staging_directory/Applications"
+
+ /usr/bin/hdiutil create \
+ -volname "AgentAwake" \
+ -srcfolder "$staging_directory" \
+ -ov \
+ -format UDZO \
+ "$dmg_path"
+ /usr/bin/hdiutil verify "$dmg_path"
+}
+
+/bin/rm -rf "$RELEASE_DIR" "$STAGING_ROOT"
+/bin/mkdir -p "$RELEASE_DIR" "$STAGING_ROOT"
+
+package_architecture arm64 "$APPLE_SILICON_DMG"
+package_architecture x86_64 "$INTEL_DMG"
+
+(
+ cd "$RELEASE_DIR"
+ /usr/bin/shasum -a 256 \
+ "${APPLE_SILICON_DMG:t}" \
+ "${INTEL_DMG:t}" \
+ > "${CHECKSUM_PATH:t}"
+)
+
+/bin/rm -rf "$STAGING_ROOT"
+
+echo "$APPLE_SILICON_DMG"
+echo "$INTEL_DMG"
+echo "$CHECKSUM_PATH"