diff --git a/AppIcon.icon/Assets/logo 2.svg b/AppIcon.icon/Assets/logo 2.svg index 5f74745e..c538585e 100644 --- a/AppIcon.icon/Assets/logo 2.svg +++ b/AppIcon.icon/Assets/logo 2.svg @@ -1,15 +1,28 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AppIcon.icon/icon.json b/AppIcon.icon/icon.json index 9e227eae..83e1860b 100644 --- a/AppIcon.icon/icon.json +++ b/AppIcon.icon/icon.json @@ -1,5 +1,5 @@ { - "fill" : "system-light", + "fill" : "system-dark", "groups" : [ { "layers" : [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6ff352..af37ccec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [v1.0.28] - 2026-06-01 + +### English +- Fix WeChat notification dismissal logic — new messages now correctly trigger Dynamic Island popup even when previous unread count is unchanged (#200) + - Previously, dismissing a WeChat notification would suppress all future notifications with the same badge count + - Now detects database signature changes to distinguish truly new messages from dismissed ones + - Ensures smooth user experience: dismiss once, but still get alerted for genuinely new messages + +### 中文 +- 修复微信通知 dismissal 逻辑——即使未读数量相同,新消息也能正确触发灵动岛弹出 (#200) + - 之前 dismiss 微信通知后,所有相同 badge 数量的通知都会被抑制不再显示 + - 现在通过检测数据库签名变化来区分真正的新消息和已被 dismiss 的消息 + - 确保流畅的用户体验:dismiss 一次,但真正的新消息仍然会收到提醒 + ## [v1.0.27] - 2026-05-30 ### English diff --git a/Info.plist b/Info.plist index 6116510a..ca43a2d3 100644 --- a/Info.plist +++ b/Info.plist @@ -3,15 +3,15 @@ CFBundleExecutable - CodeIsland + UniIsland CFBundleIconFile AppIcon CFBundleIconName AppIcon CFBundleIdentifier - com.codeisland.app + com.uniisland.desktop CFBundleName - CodeIsland + UniIsland CFBundlePackageType APPL CFBundleShortVersionString @@ -26,15 +26,15 @@ NSAppleEventsUsageDescription - CodeIsland needs to control terminal apps to jump to the correct window and tab when you click a session. + UniIsland needs to control terminal apps to jump to the correct window and tab when you click a session. NSBluetoothAlwaysUsageDescription - CodeIsland uses Bluetooth to mirror the island onto Buddy. + UniIsland uses Bluetooth to mirror the island onto Buddy. SUAutomaticallyUpdate SUEnableAutomaticChecks SUFeedURL - https://raw.githubusercontent.com/wxtsky/CodeIsland/main/appcast.xml + https://raw.githubusercontent.com/wxtsky/UniIsland/main/appcast.xml SUPublicEDKey oqLtx5s2hc8Xgsp4rEuTwnQ8UGRT4ma4tjlf+1i3YHA= SUScheduledCheckInterval diff --git a/Package.swift b/Package.swift index b4705576..fa776f70 100644 --- a/Package.swift +++ b/Package.swift @@ -2,7 +2,7 @@ import PackageDescription let package = Package( - name: "CodeIsland", + name: "UniIsland", platforms: [.macOS(.v14)], dependencies: [ // Sparkle — auto-update framework. Pinned to 2.6+ for stable @@ -12,38 +12,38 @@ let package = Package( ], targets: [ .target( - name: "CodeIslandCore", - path: "Sources/CodeIslandCore" + name: "UniIslandCore", + path: "Sources/UniIslandCore" ), .executableTarget( - name: "CodeIsland", + name: "UniIsland", dependencies: [ - "CodeIslandCore", + "UniIslandCore", .product(name: "Sparkle", package: "Sparkle"), .product(name: "Yams", package: "Yams"), ], - path: "Sources/CodeIsland", + path: "Sources/UniIsland", resources: [ .copy("Resources") ] ), .executableTarget( - name: "codeisland-bridge", - dependencies: ["CodeIslandCore"], - path: "Sources/CodeIslandBridge" + name: "uniisland-bridge", + dependencies: ["UniIslandCore"], + path: "Sources/UniIslandBridge" ), .testTarget( - name: "CodeIslandCoreTests", - dependencies: ["CodeIslandCore"], - path: "Tests/CodeIslandCoreTests" + name: "UniIslandCoreTests", + dependencies: ["UniIslandCore"], + path: "Tests/UniIslandCoreTests" ), .testTarget( - name: "CodeIslandTests", + name: "UniIslandTests", dependencies: [ - "CodeIsland", + "UniIsland", .product(name: "Yams", package: "Yams"), ], - path: "Tests/CodeIslandTests" + path: "Tests/UniIslandTests" ), ] ) diff --git a/README.md b/README.md index f68895a2..da7a74a8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- CodeIsland Logo  - CodeIsland + UniIsland Logo  + UniIsland

Real-time AI coding agent status panel for macOS Dynamic Island (Notch)
@@ -14,12 +14,12 @@ ---

- CodeIsland Panel Preview + UniIsland Panel Preview

-## What is CodeIsland? +## What is UniIsland? -CodeIsland lives in your MacBook's notch area and shows you what your AI coding agents are doing — in real time. No more switching windows to check if Claude is waiting for approval or if Codex finished its task. +UniIsland lives in your MacBook's notch area and shows you what your AI coding agents are doing — in real time. No more switching windows to check if Claude is waiting for approval or if Codex finished its task. It connects to **12 AI coding tools** via Unix socket IPC, displaying session status, tool calls, permission requests, and more — all in a compact, pixel-art styled panel. @@ -42,18 +42,18 @@ It connects to **12 AI coding tools** via Unix socket IPC, displaying session st | | Tool | Events | Jump | Status | |:---:|------|--------|------|--------| -| | Claude Code | 13 | Terminal tab | Full | -| | Codex | 3 | Terminal | Basic | -| | Gemini CLI | 6 | Terminal | Full | -| | Cursor | 10 | IDE | Full | -| | TraeCli | 10 | Terminal | Full | -| | Qoder | 10 | IDE | Full | -| | Copilot | 6 | Terminal | Full | -| | Factory | 10 | IDE | Full | -| | CodeBuddy | 10 | APP/Terminal | Full | -| | Kimi Code CLI | 10 | Terminal | Full | -| | OpenCode | All | APP/Terminal | Full | -| | Cline | 5 | VSCode | Full | +| | Claude Code | 13 | Terminal tab | Full | +| | Codex | 3 | Terminal | Basic | +| | Gemini CLI | 6 | Terminal | Full | +| | Cursor | 10 | IDE | Full | +| | TraeCli | 10 | Terminal | Full | +| | Qoder | 10 | IDE | Full | +| | Copilot | 6 | Terminal | Full | +| | Factory | 10 | IDE | Full | +| | CodeBuddy | 10 | APP/Terminal | Full | +| | Kimi Code CLI | 10 | Terminal | Full | +| | OpenCode | All | APP/Terminal | Full | +| | Cline | 5 | VSCode | Full | ## Installation @@ -61,15 +61,15 @@ It connects to **12 AI coding tools** via Unix socket IPC, displaying session st ```bash brew tap wxtsky/tap -brew install --cask codeisland +brew install --cask uniisland ``` ### Manual Download -1. Go to [Releases](https://github.com/wxtsky/CodeIsland/releases) -2. Download `CodeIsland.dmg` -3. Open the DMG and drag `CodeIsland.app` to your Applications folder -4. Launch CodeIsland — it will automatically install hooks for all detected AI tools +1. Go to [Releases](https://github.com/wxtsky/UniIsland/releases) +2. Download `UniIsland.dmg` +3. Open the DMG and drag `UniIsland.app` to your Applications folder +4. Launch UniIsland — it will automatically install hooks for all detected AI tools > **Note:** On first launch, macOS may show a security warning. Go to **System Settings → Privacy & Security** and click **Open Anyway**. @@ -78,15 +78,15 @@ brew install --cask codeisland Requires **macOS 14+** and **Swift 5.9+**. ```bash -git clone https://github.com/wxtsky/CodeIsland.git -cd CodeIsland +git clone https://github.com/wxtsky/UniIsland.git +cd UniIsland # Development (debug build + launch; Buddy Bluetooth needs the .app below) -swift build && ./.build/debug/CodeIsland +swift build && ./.build/debug/UniIsland # Release (universal binary: Apple Silicon + Intel) ./build.sh -open .build/release/CodeIsland.app +open .build/release/UniIsland.app ``` ## How It Works @@ -94,19 +94,19 @@ open .build/release/CodeIsland.app ``` AI Tool (Claude/Codex/Gemini/Cursor/...) → Hook event triggered - → codeisland-bridge (native Swift binary, ~86KB) - → Unix socket → /tmp/codeisland-.sock - → CodeIsland app receives event + → uniisland-bridge (native Swift binary, ~86KB) + → Unix socket → /tmp/uniisland-.sock + → UniIsland app receives event → Updates UI in real time ``` -CodeIsland installs lightweight hooks into each AI tool's config. When the tool triggers an event (session start, tool call, permission request, etc.), the hook sends a JSON message through a Unix socket. CodeIsland listens on this socket and updates the notch panel instantly. +UniIsland installs lightweight hooks into each AI tool's config. When the tool triggers an event (session start, tool call, permission request, etc.), the hook sends a JSON message through a Unix socket. UniIsland listens on this socket and updates the notch panel instantly. For **OpenCode**, a JS plugin connects directly to the socket — no bridge binary needed. ## Settings -CodeIsland provides a 7-tab settings panel: +UniIsland provides a 7-tab settings panel: - **General** — Language, launch at login, display selection - **Behavior** — Auto-hide, smart suppress, session cleanup @@ -125,13 +125,21 @@ CodeIsland provides a 7-tab settings panel: This project was inspired by [claude-island](https://github.com/farouqaldori/claude-island) by [@farouqaldori](https://github.com/farouqaldori). Thanks for the original idea of bringing AI agent status into the macOS notch. +### Open-Source Citations & References + +UniIsland incorporates and builds upon capabilities inspired by the following excellent open-source utilities: +- **NotchDrop** ([GitHub](https://github.com/NotchDrop/NotchDrop)) — Inspiration for the dynamic file drop zone and temporary files panel logic. +- **Boring Notch** ([GitHub](https://github.com/lsw/Boring-Notch)) — References for media playback controllers, diagnostic monitors, and Pomodoro focus workflows. +- **Atoll** ([GitHub](https://github.com/atoll-app/Atoll)) & **DynamicNotch** ([GitHub](https://github.com/shobhit99/SuperIsland)) — Inspiration for modular settings and diagnostic activity HUD design layouts. +- **SuperIsland** ([GitHub](https://github.com/shobhit99/SuperIsland)) — Core references for system battery level animations, EventKit calendar lookup count-downs, and automated virtual meeting joining. + ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/README.zh-CN.md b/README.zh-CN.md index 3ac13376..14d4ea85 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@

- CodeIsland Logo  - CodeIsland + UniIsland Logo  + UniIsland

macOS 灵动岛(刘海)实时 AI 编码 Agent 状态面板
@@ -14,12 +14,12 @@ ---

- CodeIsland Panel Preview + UniIsland Panel Preview

-## CodeIsland 是什么? +## UniIsland 是什么? -CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的工作状态。不用再频繁切窗口去看 Claude 是否在等审批、Codex 是否完成了任务。 +UniIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的工作状态。不用再频繁切窗口去看 Claude 是否在等审批、Codex 是否完成了任务。 它通过 Unix socket IPC 连接 **12 种 AI 编码工具**,在刘海面板中展示会话状态、工具调用、权限请求等信息——全部呈现在一个紧凑的像素风面板中。 @@ -42,18 +42,18 @@ CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的 | | 工具 | 事件 | 跳转 | 状态 | |:---:|------|------|------|------| -| | Claude Code | 13 | 终端标签页 | 完整 | -| | Codex | 3 | 终端 | 基础 | -| | Gemini CLI | 6 | 终端 | 完整 | -| | Cursor | 10 | IDE | 完整 | -| | TraeCli | 10 | 终端 | 完整 | -| | Qoder | 10 | IDE | 完整 | -| | Copilot | 6 | 终端 | 完整 | -| | Factory | 10 | IDE | 完整 | -| | CodeBuddy | 10 | APP/终端 | 完整 | -| | Kimi Code CLI | 10 | 终端 | 完整 | -| | OpenCode | All | APP/终端 | 完整 | -| | Cline | 5 | VSCode | 完整 | +| | Claude Code | 13 | 终端标签页 | 完整 | +| | Codex | 3 | 终端 | 基础 | +| | Gemini CLI | 6 | 终端 | 完整 | +| | Cursor | 10 | IDE | 完整 | +| | TraeCli | 10 | 终端 | 完整 | +| | Qoder | 10 | IDE | 完整 | +| | Copilot | 6 | 终端 | 完整 | +| | Factory | 10 | IDE | 完整 | +| | CodeBuddy | 10 | APP/终端 | 完整 | +| | Kimi Code CLI | 10 | 终端 | 完整 | +| | OpenCode | All | APP/终端 | 完整 | +| | Cline | 5 | VSCode | 完整 | ## 安装 @@ -61,15 +61,15 @@ CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的 ```bash brew tap wxtsky/tap -brew install --cask codeisland +brew install --cask uniisland ``` ### 手动下载 -1. 前往 [Releases](https://github.com/wxtsky/CodeIsland/releases) 页面 -2. 下载 `CodeIsland.dmg` -3. 打开 DMG,将 `CodeIsland.app` 拖入「应用程序」文件夹 -4. 启动 CodeIsland — 会自动为所有检测到的 AI 工具安装 hooks +1. 前往 [Releases](https://github.com/wxtsky/UniIsland/releases) 页面 +2. 下载 `UniIsland.dmg` +3. 打开 DMG,将 `UniIsland.app` 拖入「应用程序」文件夹 +4. 启动 UniIsland — 会自动为所有检测到的 AI 工具安装 hooks > **提示:** 首次启动时 macOS 可能弹出安全提示,前往 **系统设置 → 隐私与安全性** 点击 **仍要打开** 即可。 @@ -78,15 +78,15 @@ brew install --cask codeisland 需要 **macOS 14+** 和 **Swift 5.9+**。 ```bash -git clone https://github.com/wxtsky/CodeIsland.git -cd CodeIsland +git clone https://github.com/wxtsky/UniIsland.git +cd UniIsland # 开发模式(debug 构建 + 启动;Buddy 蓝牙需要下面的 .app) -swift build && ./.build/debug/CodeIsland +swift build && ./.build/debug/UniIsland # 发布模式(通用二进制:Apple Silicon + Intel) ./build.sh -open .build/release/CodeIsland.app +open .build/release/UniIsland.app ``` ## 工作原理 @@ -94,19 +94,19 @@ open .build/release/CodeIsland.app ``` AI 工具 (Claude/Codex/Gemini/Cursor/...) → 触发 Hook 事件 - → codeisland-bridge(原生 Swift 二进制,约 86KB) - → Unix socket → /tmp/codeisland-.sock - → CodeIsland 接收事件 + → uniisland-bridge(原生 Swift 二进制,约 86KB) + → Unix socket → /tmp/uniisland-.sock + → UniIsland 接收事件 → 实时更新 UI ``` -CodeIsland 在每个 AI 工具的配置中安装轻量级 hooks。当工具触发事件(会话开始、工具调用、权限请求等)时,hook 通过 Unix socket 发送 JSON 消息。CodeIsland 监听此 socket 并即时更新刘海面板。 +UniIsland 在每个 AI 工具的配置中安装轻量级 hooks。当工具触发事件(会话开始、工具调用、权限请求等)时,hook 通过 Unix socket 发送 JSON 消息。UniIsland 监听此 socket 并即时更新刘海面板。 **OpenCode** 使用 JS 插件直接连接 socket,无需 bridge 二进制。 ## 设置 -CodeIsland 提供 7 个标签页的设置面板: +UniIsland 提供 7 个标签页的设置面板: - **通用** — 语言、登录时启动、显示器选择 - **行为** — 自动隐藏、智能抑制、会话清理 @@ -125,13 +125,21 @@ CodeIsland 提供 7 个标签页的设置面板: 本项目受 [@farouqaldori](https://github.com/farouqaldori) 的 [claude-island](https://github.com/farouqaldori/claude-island) 启发,感谢提供了将 AI Agent 状态带入 macOS 刘海的创意。 +### 开源引用与参考致谢 + +UniIsland 在开发过程中,深度学习并借鉴了以下优秀开源工具的核心设计与功能逻辑: +- **NotchDrop** ([GitHub](https://github.com/NotchDrop/NotchDrop)) — 启发了刘海动态文件投递、缓存区以及临时文件快速 AirDrop 共享的管理逻辑。 +- **Boring Notch** ([GitHub](https://github.com/lsw/Boring-Notch)) — 参考了系统播放媒体获取、专注番茄钟流程控制,以及硬件活动状态的微仪表盘设计。 +- **Atoll** ([GitHub](https://github.com/atoll-app/Atoll)) & **DynamicNotch** ([GitHub](https://github.com/shobhit99/SuperIsland)) — 启发了刘海模块化开关、硬件 HUD 排布以及微光动画交互布局。 +- **SuperIsland** ([GitHub](https://github.com/shobhit99/SuperIsland)) — 参考了系统电池电量状态监控与充电微动效,以及通过 `EventKit` 自动关联日历日程并一键进入虚拟会议(如 Zoom、Google Meet、Teams)的便利功能。 + ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/Sources/CodeIsland/Resources/AppIcon.icns b/Sources/CodeIsland/Resources/AppIcon.icns deleted file mode 100644 index 72c66328..00000000 Binary files a/Sources/CodeIsland/Resources/AppIcon.icns and /dev/null differ diff --git a/Sources/UniIsland/AmbientWaveView.swift b/Sources/UniIsland/AmbientWaveView.swift new file mode 100644 index 00000000..7c58432d --- /dev/null +++ b/Sources/UniIsland/AmbientWaveView.swift @@ -0,0 +1,128 @@ +import SwiftUI +import UniIslandCore + +/// A beautiful, futuristic voice-assistant style neon sine wave visualizer. +/// Displays overlapping fluid waves that react dynamically to the Agent's active/idle status. +struct AmbientWaveView: View { + let status: AgentStatus + + // Premium assistant neon gradients + private static let cyanLt = Color(red: 0.15, green: 0.85, blue: 1.00) // Neon Cyan + private static let pinkLt = Color(red: 1.00, green: 0.20, blue: 0.70) // Hot Pink/Magenta + private static let violetLt = Color(red: 0.55, green: 0.25, blue: 1.00) // Bright Violet + private static let greenLt = Color(red: 0.20, green: 0.85, blue: 0.35) // WeChat green (used in wechat state) + + var body: some View { + TimelineView(.animation) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + Canvas { c, sz in + // Set up parameters based on agent status + let isThinking: Bool + switch status { + case .processing, .running: + isThinking = true + default: + isThinking = false + } + + let baseAmplitude: CGFloat = isThinking ? 9.5 : 3.5 + let baseSpeed: Double = isThinking ? 6.8 : 1.8 + let baseFrequency: CGFloat = isThinking ? 0.045 : 0.03 + + // We draw 3 overlapping waves with offset phases and speeds + drawWave( + context: c, + size: sz, + time: t, + amplitude: baseAmplitude, + speed: baseSpeed, + frequency: baseFrequency, + phaseOffset: 0.0, + gradient: Gradient(colors: [Self.cyanLt.opacity(0.8), Self.violetLt.opacity(0.4)]), + lineWidth: 2.0 + ) + + drawWave( + context: c, + size: sz, + time: t, + amplitude: baseAmplitude * 0.75, + speed: baseSpeed * 1.35, + frequency: baseFrequency * 0.8, + phaseOffset: .pi * 0.5, + gradient: Gradient(colors: [Self.pinkLt.opacity(0.7), Self.violetLt.opacity(0.3)]), + lineWidth: 1.5 + ) + + drawWave( + context: c, + size: sz, + time: t, + amplitude: baseAmplitude * 0.45, + speed: baseSpeed * 0.8, + frequency: baseFrequency * 1.25, + phaseOffset: .pi * 1.1, + gradient: Gradient(colors: [Self.cyanLt.opacity(0.6), Self.pinkLt.opacity(0.3)]), + lineWidth: 1.0 + ) + } + } + .frame(maxHeight: 32) + } + + private func drawWave( + context c: GraphicsContext, + size sz: CGSize, + time t: Double, + amplitude: CGFloat, + speed: Double, + frequency: CGFloat, + phaseOffset: CGFloat, + gradient: Gradient, + lineWidth: CGFloat + ) { + let midY = sz.height / 2 + let width = sz.width + let phase = CGFloat(t * speed) + phaseOffset + + var path = Path() + path.move(to: CGPoint(x: 0, y: midY)) + + // Sample points along the width of the canvas + let step: CGFloat = 2.0 + for x in stride(from: 0.0, through: width, by: step) { + // Apply a nice envelope so the wave pinches down at both edges (left/right) + // envelope ranges from 0 to 1, being 0 at edges and 1 in the middle + let normalizedX = x / width + let envelope = sin(normalizedX * .pi) + + let y = midY + sin(x * frequency - phase) * amplitude * envelope + path.addLine(to: CGPoint(x: x, y: y)) + } + + // Draw a glowing shadow under the path + c.drawLayer { shadowContext in + shadowContext.addFilter(.blur(radius: 1.5)) + shadowContext.stroke( + path, + with: .linearGradient( + gradient, + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: width, y: 0) + ), + lineWidth: lineWidth * 1.8 + ) + } + + // Draw the main wave path + c.stroke( + path, + with: .linearGradient( + gradient, + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: width, y: 0) + ), + lineWidth: lineWidth + ) + } +} diff --git a/Sources/CodeIsland/AntiGravityView.swift b/Sources/UniIsland/AntiGravityView.swift similarity index 99% rename from Sources/CodeIsland/AntiGravityView.swift rename to Sources/UniIsland/AntiGravityView.swift index be98f6df..8d7e0412 100644 --- a/Sources/CodeIsland/AntiGravityView.swift +++ b/Sources/UniIsland/AntiGravityView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// AntiGravityBot — AntiGravity mascot, rainbow gradient swoosh character. /// Multicolor gradient inspired by the AntiGravity "A" logo. diff --git a/Sources/CodeIsland/AppDelegate.swift b/Sources/UniIsland/AppDelegate.swift similarity index 98% rename from Sources/CodeIsland/AppDelegate.swift rename to Sources/UniIsland/AppDelegate.swift index 4e4a7733..2ca1a13e 100644 --- a/Sources/CodeIsland/AppDelegate.swift +++ b/Sources/UniIsland/AppDelegate.swift @@ -1,11 +1,11 @@ import AppKit import SwiftUI import os.log -import CodeIslandCore +import UniIslandCore @MainActor class AppDelegate: NSObject, NSApplicationDelegate { - nonisolated private static let log = Logger(subsystem: "com.codeisland", category: "AppDelegate") + nonisolated private static let log = Logger(subsystem: "com.uniisland", category: "AppDelegate") var panelController: PanelWindowController? private var hookServer: HookServer? @@ -16,7 +16,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let appState = AppState() func applicationDidFinishLaunching(_ notification: Notification) { - ProcessInfo.processInfo.disableAutomaticTermination("CodeIsland must stay running") + ProcessInfo.processInfo.disableAutomaticTermination("UniIsland must stay running") ProcessInfo.processInfo.disableSuddenTermination() // Pre-set app icon so Dock/menu bar use the packaged bundle icon. NSApp.applicationIconImage = SettingsWindowController.bundleAppIcon() diff --git a/Sources/CodeIsland/AppState+CodexAppServer.swift b/Sources/UniIsland/AppState+CodexAppServer.swift similarity index 98% rename from Sources/CodeIsland/AppState+CodexAppServer.swift rename to Sources/UniIsland/AppState+CodexAppServer.swift index 09bd8c0c..da456e7a 100644 --- a/Sources/CodeIsland/AppState+CodexAppServer.swift +++ b/Sources/UniIsland/AppState+CodexAppServer.swift @@ -1,6 +1,6 @@ import Foundation import AppKit -import CodeIslandCore +import UniIslandCore extension AppState { /// Session ID prefix applied to Codex threads surfaced via the app-server. @@ -82,7 +82,7 @@ extension AppState { do { try client.start() let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - try client.initializeHandshake(clientName: "CodeIsland", clientVersion: version) + try client.initializeHandshake(clientName: "UniIsland", clientVersion: version) } catch { client.stop() return diff --git a/Sources/CodeIsland/AppState+ToolUseCache.swift b/Sources/UniIsland/AppState+ToolUseCache.swift similarity index 96% rename from Sources/CodeIsland/AppState+ToolUseCache.swift rename to Sources/UniIsland/AppState+ToolUseCache.swift index bc2a0cca..5c058aa3 100644 --- a/Sources/CodeIsland/AppState+ToolUseCache.swift +++ b/Sources/UniIsland/AppState+ToolUseCache.swift @@ -1,8 +1,9 @@ import Foundation +import SwiftUI import os.log -import CodeIslandCore +import UniIslandCore -private let log = Logger(subsystem: "com.codeisland", category: "PermissionDeny") +private let log = Logger(subsystem: "com.uniisland", category: "PermissionDeny") /// Cached metadata for an in-flight tool_use_id, written on PreToolUse and consumed by /// downstream PermissionRequest / PostToolUse events. @@ -70,7 +71,9 @@ extension AppState { if wasHead { if permissionQueue.isEmpty { if case .approvalCard = surface { - surface = .collapsed + withAnimation(NotchAnimation.close) { + surface = .collapsed + } } } else { showNextPending() diff --git a/Sources/CodeIsland/AppState+TranscriptTailer.swift b/Sources/UniIsland/AppState+TranscriptTailer.swift similarity index 98% rename from Sources/CodeIsland/AppState+TranscriptTailer.swift rename to Sources/UniIsland/AppState+TranscriptTailer.swift index 8348db00..0b3c48f5 100644 --- a/Sources/CodeIsland/AppState+TranscriptTailer.swift +++ b/Sources/UniIsland/AppState+TranscriptTailer.swift @@ -1,5 +1,5 @@ import Foundation -import CodeIslandCore +import UniIslandCore extension AppState { /// Start watching a session's transcript file for appended lines. Safe to call diff --git a/Sources/CodeIsland/AppState.swift b/Sources/UniIsland/AppState.swift similarity index 81% rename from Sources/CodeIsland/AppState.swift rename to Sources/UniIsland/AppState.swift index 6492d2fe..5b5587fa 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/UniIsland/AppState.swift @@ -3,9 +3,12 @@ import CoreServices import os.log import SQLite3 import CryptoKit -import CodeIslandCore +import UniIslandCore +import ApplicationServices +import EventKit +import IOKit.ps -private let log = Logger(subsystem: "com.codeisland", category: "AppState") +private let log = Logger(subsystem: "com.uniisland", category: "AppState") struct CodexSubagentMetadata: Equatable, Sendable { let parentThreadId: String @@ -46,6 +49,35 @@ final class AppState { var permissionQueue: [PermissionRequest] = [] var questionQueue: [QuestionRequest] = [] + var isDraggingOver = false + var droppedFiles: [URL] = [] + + // ========================================== + // MARK: - WIDGETS STATES + // ========================================== + var pomodoroActive = false + var pomodoroRemaining: TimeInterval = 25.0 * 60.0 + var pomodoroPaused = false + var pomodoroTotalDuration: TimeInterval = 25.0 * 60.0 + var pomodoroLabel = "专注" + @ObservationIgnored private var pomodoroTimer: Timer? + + var mediaTrackName = "" + var mediaArtistName = "" + var isMediaPlaying = false + @ObservationIgnored private var mediaMonitorTimer: Timer? + + var calendarEventTitle = "无近期日程" + var calendarEventCountdown = "暂无日程" + var calendarJoinLink: String? = nil + @ObservationIgnored private var calendarMonitorTimer: Timer? + + init() { + loadDroppedFiles() + startDropsCleanupTimer() + startWidgetsTimers() + } + @ObservationIgnored private(set) var recentHookEvents: [DiagnosticHookEvent] = [] @ObservationIgnored @@ -123,6 +155,7 @@ final class AppState { private var exitingSessions: [String: ProcessIdentity] = [:] private var saveTimer: Timer? private var fsEventStream: FSEventStreamRef? + private var weChatFSEventStream: FSEventStreamRef? private var lastFSScanTime: Date = .distantPast private var discoveryScanTask: Task? private var pendingDiscoveryRescan = false @@ -140,6 +173,7 @@ final class AppState { private var modelReadRetryAt: [String: Date] = [:] private var dismissedPermissionSessionIds: Set = [] + private var dismissedDiscoveredSessionIds: Set = [] private func nextVisiblePermissionIndex() -> Int? { permissionQueue.firstIndex { request in let sid = request.event.sessionId ?? "default" @@ -550,6 +584,33 @@ final class AppState { scheduleSave() } + func dismissSessionFromIsland(_ sessionId: String) { + guard sessions[sessionId] != nil else { return } + guard !permissionQueue.contains(where: { ($0.event.sessionId ?? "default") == sessionId }), + !questionQueue.contains(where: { ($0.event.sessionId ?? "default") == sessionId }) else { + return + } + + dismissedDiscoveredSessionIds.insert(sessionId) + sessions.removeValue(forKey: sessionId) + stopMonitor(sessionId) + detachTranscriptTailer(sessionId: sessionId) + exitingSessions.removeValue(forKey: sessionId) + modelReadRetryAt.removeValue(forKey: sessionId) + completionQueue.removeAll { $0 == sessionId } + if activeSessionId == sessionId { + activeSessionId = mostActiveSessionId() + } + if surface.sessionId == sessionId { + withAnimation(NotchAnimation.close) { + surface = .collapsed + } + } + startRotationIfNeeded() + refreshDerivedState() + scheduleSave() + } + // MARK: - Compact bar mascot rotation /// Cached sorted active session IDs — refreshed by refreshActiveIds() @@ -749,7 +810,9 @@ final class AppState { private func showCompletion(_ sessionId: String) { // Fast path: terminal not even frontmost — show immediately guard shouldSuppressAppLevel(for: sessionId) else { - doShowCompletion(sessionId) + withAnimation(NotchAnimation.pop) { + doShowCompletion(sessionId) + } return } @@ -864,7 +927,7 @@ final class AppState { // because deriveSessionSummary echoes the most recently active source. // Active work always wins (running / processing / waiting* status). let effectiveSource: String - if summary.status == .idle { + if sessions.isEmpty { effectiveSource = SettingsManager.shared.defaultSource } else { effectiveSource = summary.primarySource @@ -907,6 +970,7 @@ final class AppState { } let sessionId = event.sessionId ?? "default" + dismissedDiscoveredSessionIds.remove(sessionId) // Skip Codex APP internal sessions (title generation, etc.) — they have no transcript if (event.rawJSON["_source"] as? String) == "codex" @@ -1044,6 +1108,7 @@ final class AppState { func handlePermissionRequest(_ event: HookEvent, continuation: CheckedContinuation) { let sessionId = event.sessionId ?? "default" + dismissedDiscoveredSessionIds.remove(sessionId) if sessions[sessionId] == nil { sessions[sessionId] = SessionSnapshot() } @@ -1085,7 +1150,9 @@ final class AppState { // If user is already browsing the session list, keep them there and // let inline controls handle approval without stealing focus. if surface != .sessionList { - surface = .approvalCard(sessionId: sessionId) + withAnimation(NotchAnimation.open) { + surface = .approvalCard(sessionId: sessionId) + } } SoundManager.shared.handleEvent("PermissionRequest") } @@ -1241,6 +1308,7 @@ final class AppState { func handleQuestion(_ event: HookEvent, continuation: CheckedContinuation) { let sessionId = event.sessionId ?? "default" + dismissedDiscoveredSessionIds.remove(sessionId) if sessions[sessionId] == nil { sessions[sessionId] = SessionSnapshot() } @@ -1273,6 +1341,7 @@ final class AppState { func handleAskUserQuestion(_ event: HookEvent, continuation: CheckedContinuation) { let sessionId = event.sessionId ?? "default" + dismissedDiscoveredSessionIds.remove(sessionId) if sessions[sessionId] == nil { sessions[sessionId] = SessionSnapshot() } @@ -1570,13 +1639,17 @@ final class AppState { activeSessionId = sid // When the session list is open, keep it open; approvals can be handled inline. if surface != .sessionList { - surface = .approvalCard(sessionId: sid) + withAnimation(NotchAnimation.open) { + surface = .approvalCard(sessionId: sid) + } } return true } else if let next = questionQueue.first { let sid = next.event.sessionId ?? "default" activeSessionId = sid - surface = .questionCard(sessionId: sid) + withAnimation(NotchAnimation.open) { + surface = .questionCard(sessionId: sid) + } return true } else if !completionQueue.isEmpty { while let next = completionQueue.first { @@ -1588,9 +1661,13 @@ final class AppState { } return false } else if case .approvalCard = surface { - surface = .collapsed + withAnimation(NotchAnimation.close) { + surface = .collapsed + } } else if case .questionCard = surface { - surface = .collapsed + withAnimation(NotchAnimation.close) { + surface = .collapsed + } } return false } @@ -2065,6 +2142,9 @@ final class AppState { requestDiscoveryScan() // Watch all known session-store roots so discovery keeps working when hooks are missed. startProjectsWatcher() + + // Start WeChat notification observer poller + startWeChatPoller() } /// FSEventStream on known session-store roots — fires when transcript/event files change. @@ -2101,6 +2181,713 @@ final class AppState { log.info("Discovery watcher started on \(watchRoots.joined(separator: ", "))") } + @ObservationIgnored + private var weChatTimer: Timer? + /// Last seen notification-DB signature (size+mtime) — re-read content only when it changes. + @ObservationIgnored + private var lastWeChatDBSignature = "" + @ObservationIgnored + private var lastWeChatUnreadKey = "" + @ObservationIgnored + private var lastDisplayedWeChatUnreadKey = "" + @ObservationIgnored + private var dismissedWeChatUnreadKey: String? + @ObservationIgnored + private var weChatBurstUntil: Date? + @ObservationIgnored + private var pendingImmediateWeChatPoll = false + + nonisolated static func weChatPollInterval(isBursting: Bool, burstUntil: Date?, now: Date = Date()) -> TimeInterval { + if isBursting, let burstUntil, burstUntil > now { + return 0.03 + } + return 0.2 + } + + func startWeChatPoller() { + guard weChatTimer == nil else { return } + // For LSUIElement apps the OS suppresses the standard accessibility prompt. + // Open System Settings directly on first run if permission is missing. + if !AXIsProcessTrusted() { + let hasPrompted = UserDefaults.standard.bool(forKey: "hasPromptedAccessibility") + if !hasPrompted { + UserDefaults.standard.set(true, forKey: "hasPromptedAccessibility") + log.info("Accessibility not granted — opening System Settings (first-time prompt)") + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + NSWorkspace.shared.open( + URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! + ) + } + } + } + scheduleNextWeChatPoll(after: 0.05) + startWeChatNotificationDBWatcher() + log.info("WeChat notification poller started") + } + + private func scheduleNextWeChatPoll(after interval: TimeInterval? = nil) { + weChatTimer?.invalidate() + let nextInterval = interval ?? Self.weChatPollInterval( + isBursting: weChatBurstUntil.map { $0 > Date() } ?? false, + burstUntil: weChatBurstUntil + ) + weChatTimer = Timer.scheduledTimer(withTimeInterval: nextInterval, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.pollWeChatBadge() + } + } + } + + private func enterWeChatBurstWindow(seconds: TimeInterval = 3.0) { + let until = Date().addingTimeInterval(seconds) + if weChatBurstUntil.map({ $0 < until }) ?? true { + weChatBurstUntil = until + } + } + + private func requestImmediateWeChatPoll() { + guard !pendingImmediateWeChatPoll else { return } + pendingImmediateWeChatPoll = true + enterWeChatBurstWindow() + scheduleNextWeChatPoll(after: 0.01) + } + + private func startWeChatNotificationDBWatcher() { + guard weChatFSEventStream == nil else { return } + let home = FileManager.default.homeDirectoryForCurrentUser.path + let dbDir = home + "/Library/Group Containers/group.com.apple.usernoted/db2" + guard FileManager.default.fileExists(atPath: dbDir) else { return } + + var context = FSEventStreamContext() + context.info = Unmanaged.passUnretained(self).toOpaque() + let stream = FSEventStreamCreate( + nil, + { (_, info, _, _, _, _) in + guard let info else { return } + let appState = Unmanaged.fromOpaque(info).takeUnretainedValue() + Task { @MainActor in + appState.requestImmediateWeChatPoll() + } + }, + &context, + [dbDir] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.05, + FSEventStreamCreateFlags(kFSEventStreamCreateFlagUseCFTypes) + ) + + guard let stream else { return } + FSEventStreamSetDispatchQueue(stream, .main) + FSEventStreamStart(stream) + weChatFSEventStream = stream + } + + var isWeChatPermissionAlertDismissed = false + private var weChatPermissionAlertSnoozedUntil: Date? + + func dismissWeChatPermissionAlert() { + isWeChatPermissionAlertDismissed = true + weChatPermissionAlertSnoozedUntil = nil + removeWeChatPermissionAlertSession() + } + + func openWeChatAccessibilitySettings() { + isWeChatPermissionAlertDismissed = false + weChatPermissionAlertSnoozedUntil = Date().addingTimeInterval(90) + removeWeChatPermissionAlertSession() + NSWorkspace.shared.open( + URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! + ) + scheduleNextWeChatPoll(after: 0.5) + } + + private var isWeChatPermissionAlertSnoozed: Bool { + guard let until = weChatPermissionAlertSnoozedUntil else { return false } + if until > Date() { return true } + weChatPermissionAlertSnoozedUntil = nil + return false + } + + private func removeWeChatPermissionAlertSession() { + let permId = "wechat_permission" + let hadSession = sessions.removeValue(forKey: permId) != nil + if activeSessionId == permId { + activeSessionId = mostActiveSessionId() + } + if surface == .sessionList && activeSessionId == nil { + withAnimation(NotchAnimation.close) { + surface = .collapsed + } + } + guard hadSession else { return } + refreshDerivedState() + } + + private func pollWeChatBadge() { + pendingImmediateWeChatPoll = false + + guard AXIsProcessTrusted() else { + // Accessibility permission is missing. Do not poll dock badge. + // But if WeChat is running and we haven't dismissed the alert, show the permission warning card! + let isWeChatRunning = NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == "com.tencent.xinWeChat" + } + let isSnoozed = isWeChatPermissionAlertSnoozed + if isWeChatRunning && !isWeChatPermissionAlertDismissed && !isSnoozed { + let permId = "wechat_permission" + if sessions[permId] == nil { + var snap = SessionSnapshot() + snap.source = "wechat_permission" + snap.cwd = "/" + snap.currentTool = "微信" + snap.toolDescription = "微信监控需要辅助功能权限" + snap.status = .waitingQuestion + sessions[permId] = snap + + if activeSessionId == nil || activeSessionId == "default" { + activeSessionId = permId + } + + // Auto-expand the island to show the permission warning + if surface == .collapsed { + withAnimation(NotchAnimation.open) { + surface = .sessionList + activeSessionId = permId + } + } + + refreshDerivedState() + } + } else { + removeWeChatPermissionAlertSession() + } + + scheduleNextWeChatPoll(after: isSnoozed ? 0.5 : 2.0) + return + } + + weChatPermissionAlertSnoozedUntil = nil + // Accessibility is trusted: clean up the permission alert if present + removeWeChatPermissionAlertSession() + + let isWeChatRunning = NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == "com.tencent.xinWeChat" + } + + guard isWeChatRunning else { + lastWeChatDBSignature = "" + lastWeChatUnreadKey = "" + lastDisplayedWeChatUnreadKey = "" + hideWeChatSessionFromIsland(clearDismissal: true) + weChatBurstUntil = nil + scheduleNextWeChatPoll() + return + } + + let previousUnreadKey = lastWeChatUnreadKey + let previousSignature = lastWeChatDBSignature + Task.detached { + let badge = Self.readWeChatBadgeViaAX() + let signature = badge.isEmpty ? "" : Self.weChatDBSignature() + let unreadKey = badge.isEmpty ? "" : "\(badge)|\(signature)" + await MainActor.run { [weak self] in + guard let self else { return } + if !unreadKey.isEmpty && (unreadKey != previousUnreadKey || signature != previousSignature) { + self.enterWeChatBurstWindow() + } + self.lastWeChatDBSignature = signature + self.lastWeChatUnreadKey = unreadKey + self.handleWeChatBadgeResult(badge, unreadKey: unreadKey) + self.scheduleNextWeChatPoll() + } + } + } + + private func handleWeChatBadgeResult(_ badge: String, unreadKey: String) { + let wechatSessionId = "wechat" + + if !badge.isEmpty { + // Check if this is a new message vs the same dismissed one + // If the signature changed (new message arrived), clear the dismissal state + let isSameUnreadKey = dismissedWeChatUnreadKey == unreadKey + let hasNewSignature = !unreadKey.isEmpty && lastWeChatUnreadKey != unreadKey + + if isSameUnreadKey && !hasNewSignature { + // Same message that was already dismissed — don't show again + hideWeChatSessionFromIsland(clearDismissal: false) + return + } + + // New message detected (different unreadKey or signature changed) — clear dismissal + if hasNewSignature { + dismissedWeChatUnreadKey = nil + } + + if sessions[wechatSessionId] == nil { + sessions[wechatSessionId] = SessionSnapshot() + sessions[wechatSessionId]?.source = "wechat" + sessions[wechatSessionId]?.cwd = "/Applications/WeChat.app" + sessions[wechatSessionId]?.termBundleId = "com.tencent.xinWeChat" + sessions[wechatSessionId]?.termApp = "微信" + } + + let displayMsg = "微信有新消息 (\(badge))" + + // .waitingQuestion drives the "alert" mascot (jump + ! ) and the pulsing bell + let isNewUnreadRecord = lastDisplayedWeChatUnreadKey != unreadKey + if sessions[wechatSessionId]?.toolDescription != displayMsg || sessions[wechatSessionId]?.status != .waitingQuestion || isNewUnreadRecord { + sessions[wechatSessionId]?.status = .waitingQuestion + sessions[wechatSessionId]?.currentTool = "微信" + sessions[wechatSessionId]?.toolDescription = displayMsg + sessions[wechatSessionId]?.lastActivity = Date() + lastDisplayedWeChatUnreadKey = unreadKey + + if activeSessionId == nil || activeSessionId == "default" { + activeSessionId = wechatSessionId + } + + refreshDerivedState() + startRotationIfNeeded() + + // Auto-expand the island to show WeChat notification in session list + // This is independent of the autoExpandOnCompletion setting + if surface == .collapsed { + withAnimation(NotchAnimation.open) { + surface = .sessionList + activeSessionId = wechatSessionId + } + + // Auto-collapse after 8 seconds so it doesn't stay expanded forever! + autoCollapseTask?.cancel() + autoCollapseTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 8_000_000_000) + guard let self = self else { return } + guard !Task.isCancelled else { return } + if self.surface == .sessionList && self.activeSessionId == wechatSessionId { + withAnimation(NotchAnimation.close) { + self.surface = .collapsed + } + } + } + } + } + } else { + // All read + withAnimation(NotchAnimation.close) { + lastWeChatUnreadKey = "" + lastDisplayedWeChatUnreadKey = "" + hideWeChatSessionFromIsland(clearDismissal: true) + } + } + } + + func dismissWeChatFromIsland() { + guard !lastWeChatUnreadKey.isEmpty else { + hideWeChatSessionFromIsland(clearDismissal: true) + return + } + dismissedWeChatUnreadKey = lastWeChatUnreadKey + lastDisplayedWeChatUnreadKey = lastWeChatUnreadKey + hideWeChatSessionFromIsland(clearDismissal: false) + } + + private func hideWeChatSessionFromIsland(clearDismissal: Bool) { + withAnimation(NotchAnimation.close) { + if clearDismissal { + dismissedWeChatUnreadKey = nil + } + + let wechatSessionId = "wechat" + let hadSession = sessions[wechatSessionId] != nil + sessions.removeValue(forKey: wechatSessionId) + completionQueue.removeAll { $0 == wechatSessionId } + if activeSessionId == wechatSessionId { + activeSessionId = mostActiveSessionId() + } + var didCollapseSurface = false + if surface.sessionId == wechatSessionId { + didCollapseSurface = true + surface = .collapsed + } + guard hadSession || didCollapseSurface else { return } + startRotationIfNeeded() + refreshDerivedState() + } + } + + // Directory where dropped files are cached + nonisolated static var dropsDirectory: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dir = appSupport.appendingPathComponent("UniIsland/Drops", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Scan drops directory and reload files on main thread + func loadDroppedFiles() { + let dir = Self.dropsDirectory + do { + let urls = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.creationDateKey], + options: [.skipsHiddenFiles] + ) + // Sort by creation date descending (newest first) + self.droppedFiles = urls.sorted { url1, url2 in + let date1 = (try? url1.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast + let date2 = (try? url2.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast + return date1 > date2 + } + updateDropsSession() + } catch { + log.error("Failed to load dropped files: \(error)") + } + } + + /// Handles files dropped via NSItemProvider + func handleDroppedFiles(_ providers: [NSItemProvider]) { + Task { + var loadedURLs: [URL] = [] + for provider in providers { + if provider.hasItemConformingToTypeIdentifier("public.file-url") { + let url = await withCheckedContinuation { (continuation: CheckedContinuation) in + provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { data, _ in + if let data = data as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) { + continuation.resume(returning: url) + } else if let url = data as? URL { + continuation.resume(returning: url) + } else { + continuation.resume(returning: nil) + } + } + } + if let url { + loadedURLs.append(url) + } + } + } + + guard !loadedURLs.isEmpty else { return } + + let destDir = Self.dropsDirectory + for url in loadedURLs { + let destURL = destDir.appendingPathComponent(url.lastPathComponent) + // Overwrite if exists + try? FileManager.default.removeItem(at: destURL) + do { + try FileManager.default.copyItem(at: url, to: destURL) + } catch { + log.error("Failed to copy dropped file: \(error)") + } + } + + await MainActor.run { + self.loadDroppedFiles() + SoundManager.shared.preview("8bit_complete") + + // Auto-expand the island to show dropped files + if self.activeSessionId == nil || self.activeSessionId == "default" { + self.activeSessionId = "drops" + } + enqueueCompletion("drops") + } + } + } + + /// Delete a dropped file + func deleteDroppedFile(_ url: URL) { + try? FileManager.default.removeItem(at: url) + loadDroppedFiles() + } + + /// AirDrop a file using native sharing service + func shareDroppedFile(_ url: URL, sender: NSView) { + let picker = NSSharingServicePicker(items: [url]) + picker.show(relativeTo: .zero, of: sender, preferredEdge: .minY) + } + + /// Export / Save As... a dropped file to user selected location + func saveDroppedFileAs(_ url: URL) { + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = url.lastPathComponent + savePanel.title = "保存文件到..." + savePanel.prompt = "保存" + + guard savePanel.runModal() == .OK, let destURL = savePanel.url else { return } + + DispatchQueue.global(qos: .userInitiated).async { + do { + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.removeItem(at: destURL) + } + try FileManager.default.copyItem(at: url, to: destURL) + + DispatchQueue.main.async { + SoundManager.shared.preview("8bit_complete") + } + } catch { + log.error("Failed to save dropped file: \(error)") + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "保存失败" + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + } + } + + /// Manage the synthesized "drops" session card in dynamic island + private func updateDropsSession() { + let sessionId = "drops" + if !droppedFiles.isEmpty { + if sessions[sessionId] == nil { + sessions[sessionId] = SessionSnapshot() + sessions[sessionId]?.source = "drops" + sessions[sessionId]?.cwd = Self.dropsDirectory.path + sessions[sessionId]?.termBundleId = "com.apple.Finder" + sessions[sessionId]?.termApp = "Finder" + } + let count = droppedFiles.count + sessions[sessionId]?.status = .waitingApproval // Pulse bell to notify user of drops + sessions[sessionId]?.currentTool = "Drops" + sessions[sessionId]?.toolDescription = "已存入 \(count) 个临时文件\n点击打开,Option+点击清除" + sessions[sessionId]?.lastActivity = Date() + + if activeSessionId == nil || activeSessionId == "default" { + activeSessionId = sessionId + } + } else { + sessions.removeValue(forKey: sessionId) + completionQueue.removeAll { $0 == sessionId } + if activeSessionId == sessionId { + activeSessionId = mostActiveSessionId() + } + if surface.sessionId == sessionId { + withAnimation(NotchAnimation.close) { + surface = .collapsed + } + } + } + refreshDerivedState() + } + + @ObservationIgnored + private var dropsCleanupTimer: Timer? + + private func startDropsCleanupTimer() { + dropsCleanupTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.cleanupOldDroppedFiles() + } + } + cleanupOldDroppedFiles() // Initial run + } + + private func cleanupOldDroppedFiles() { + let dir = Self.dropsDirectory + let fileManager = FileManager.default + do { + let urls = try fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.creationDateKey], + options: [.skipsHiddenFiles] + ) + let now = Date() + for url in urls { + if let date = (try? url.resourceValues(forKeys: [.creationDateKey]).creationDate), + now.timeIntervalSince(date) > 86400 { // 24 hours + try? fileManager.removeItem(at: url) + log.info("Auto-cleaned old dropped file: \(url.lastPathComponent)") + } + } + loadDroppedFiles() + } catch { + log.error("Failed to clean dropped files: \(error)") + } + } + + /// Reads the WeChat Dock badge label via AXUIElement (no AppleScript/System Events needed). + /// Returns the badge string (e.g. "3", "99+") or "" if none / inaccessible. + private nonisolated static func readWeChatBadgeViaAX() -> String { + guard let dock = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first else { + return "" + } + let dockElem = AXUIElementCreateApplication(dock.processIdentifier) + + // Dock top-level children → look for AXList + var children: AnyObject? + let childErr = AXUIElementCopyAttributeValue(dockElem, kAXChildrenAttribute as CFString, &children) + guard childErr == .success, let childList = children as? [AXUIElement] else { + // -25211 (kAXErrorAPIDisabled) means Accessibility permission is missing. + if childErr == .apiDisabled { + log.error("WeChat badge: Accessibility permission not granted (open System Settings → Privacy → Accessibility)") + } + return "" + } + + // Find the first AXList (the app row in the Dock) + var dockList: AXUIElement? + for child in childList { + var role: AnyObject? + if AXUIElementCopyAttributeValue(child, kAXRoleAttribute as CFString, &role) == .success, + (role as? String) == "AXList" { + dockList = child + break + } + } + guard let dockList else { return "" } + + // Enumerate Dock items looking for WeChat + var items: AnyObject? + guard AXUIElementCopyAttributeValue(dockList, kAXChildrenAttribute as CFString, &items) == .success, + let dockItems = items as? [AXUIElement] else { + return "" + } + + var foundBadge = "" + for item in dockItems { + var titleVal: AnyObject? + AXUIElementCopyAttributeValue(item, kAXTitleAttribute as CFString, &titleVal) + let title = titleVal as? String ?? "" + if title == "微信" || title == "WeChat" { + var badgeVal: AnyObject? + AXUIElementCopyAttributeValue(item, "AXStatusLabel" as CFString, &badgeVal) + let badge = (badgeVal as? String) ?? "" + if !badge.isEmpty { + return badge + } + foundBadge = badge + } + } + return foundBadge + } + + /// A single WeChat notification: sender (title) + message text (body). + struct WeChatNotif: Equatable, Sendable { let sender: String; let body: String } + + /// Cheap change-signal for the notification DB: combined size+mtime of db and -wal. + /// Lets the poller skip the (relatively expensive) copy+read unless something changed. + private nonisolated static func weChatDBSignature() -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let base = home + "/Library/Group Containers/group.com.apple.usernoted/db2/db" + let fm = FileManager.default + var parts: [String] = [] + for suffix in ["", "-wal"] { + if let attrs = try? fm.attributesOfItem(atPath: base + suffix) { + let size = (attrs[.size] as? Int) ?? 0 + let mod = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + parts.append("\(size):\(mod)") + } + } + return parts.joined(separator: "|") + } + + /// Reads the most recent WeChat notifications (newest first) from the macOS Notification + /// Center database. Returns [] when unavailable (no Full Disk Access, no notifications). + private nonisolated static func readWeChatNotifications(limit: Int) -> [WeChatNotif] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let srcBase = home + "/Library/Group Containers/group.com.apple.usernoted/db2/db" + let fm = FileManager.default + guard fm.fileExists(atPath: srcBase) else { return [] } + + // The notification DB runs in WAL mode: recent messages live in the -wal log and a + // plain read-only open of the live main file misses them until macOS checkpoints + // (which can lag for minutes → "shows old message, then the new one later"). + // Copy the main DB + -wal into a temp file and open the copy READ-WRITE so SQLite + // runs WAL recovery and we always see the latest committed records. + let tmpBase = NSTemporaryDirectory() + "ci_usernoted_\(UUID().uuidString).db" + defer { for s in ["", "-wal", "-shm"] { try? fm.removeItem(atPath: tmpBase + s) } } + do { + try fm.copyItem(atPath: srcBase, toPath: tmpBase) + } catch { + return [] + } + if fm.fileExists(atPath: srcBase + "-wal") { + try? fm.copyItem(atPath: srcBase + "-wal", toPath: tmpBase + "-wal") + } + + var db: OpaquePointer? + guard sqlite3_open_v2(tmpBase, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_NOMUTEX, nil) == SQLITE_OK, + let db else { + if let db { sqlite3_close_v2(db) } + return [] + } + defer { sqlite3_close_v2(db) } + sqlite3_busy_timeout(db, 500) + + let sql = """ + SELECT record.data FROM record + JOIN app ON record.app_id = app.app_id + WHERE app.identifier LIKE '%xinwechat%' + ORDER BY record.rec_id DESC LIMIT \(max(1, limit)) + """ + guard let stmt = prepareSQLiteStatement(db: db, sql: sql) else { return [] } + defer { sqlite3_finalize(stmt) } + + var result: [WeChatNotif] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let blobPtr = sqlite3_column_blob(stmt, 0) else { continue } + let size = Int(sqlite3_column_bytes(stmt, 0)) + guard size > 0 else { continue } + let data = Data(bytes: blobPtr, count: size) + // The blob is a binary plist; message is under req.titl (sender) / req.body (text). + guard let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), + let dict = plist as? [String: Any], + let req = dict["req"] as? [String: Any] else { continue } + let title = (req["titl"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let body = (req["body"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if title.isEmpty && body.isEmpty { continue } + result.append(WeChatNotif(sender: title, body: body)) + } + return result + } + + /// Builds the island text from recent notifications. Multiple senders are newline-separated; + /// when crowded, show who sent messages rather than squeezing every message body in. + nonisolated static func summarizeWeChatNotifications(_ notifs: [WeChatNotif], badge: String) -> String { + let unreadNotifs = unreadWeChatNotificationsForDisplay(notifs, badge: badge) + // Group by sender, newest first, keeping each sender's most recent message. + var seen = Set() + var grouped: [WeChatNotif] = [] + for n in unreadNotifs { + let key = n.sender.isEmpty ? n.body : n.sender + if seen.insert(key).inserted { grouped.append(n) } + } + func line(_ n: WeChatNotif) -> String { + if n.sender.isEmpty { return n.body } + if n.body.isEmpty { return n.sender } + return "\(n.sender): \(n.body)" + } + switch grouped.count { + case 0: + return badge.isEmpty ? "新消息" : "新消息 (\(badge))" + case 1: + return line(grouped[0]) + case 2: + return grouped.prefix(2).map(line).joined(separator: "\n") + default: + let names = grouped.map { $0.sender.isEmpty ? "有人" : $0.sender } + let shown = names.prefix(4).map { "\($0) 发来消息" }.joined(separator: "\n") + if grouped.count <= 4 { + return shown + } + return shown + "\n等 \(grouped.count) 人发来消息" + } + } + + nonisolated static func unreadWeChatNotificationsForDisplay(_ notifs: [WeChatNotif], badge: String) -> [WeChatNotif] { + let trimmed = badge.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix("+"), + let value = Int(trimmed.dropLast()) { + return Array(notifs.prefix(max(1, value))) + } + if let value = Int(trimmed) { + return Array(notifs.prefix(max(1, value))) + } + return notifs + } + /// Called by FSEventStream when a known session-store directory changes. nonisolated private func handleProjectsDirChange() { Task { @MainActor [weak self] in @@ -2142,6 +2929,10 @@ final class AppState { private func integrateDiscovered(_ discovered: [DiscoveredSession]) { var didMutate = false for info in discovered { + if dismissedDiscoveredSessionIds.contains(info.sessionId) { + continue + } + if routeDiscoveredSubsessionIfNeeded(info) { didMutate = true continue @@ -2513,7 +3304,15 @@ final class AppState { FSEventStreamRelease(stream) fsEventStream = nil } + if let stream = weChatFSEventStream { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + weChatFSEventStream = nil + } cleanupTimer?.invalidate() + weChatTimer?.invalidate() + weChatTimer = nil cleanupTimer = nil saveTimer?.invalidate() saveTimer = nil @@ -2532,6 +3331,12 @@ final class AppState { FSEventStreamInvalidate(stream) FSEventStreamRelease(stream) } + if let stream = weChatFSEventStream { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + weChatTimer?.invalidate() discoveryScanTask?.cancel() for (_, monitor) in processMonitors { monitor.source.cancel() @@ -4520,6 +5325,256 @@ final class AppState { return (model, recent) } + + // ========================================== + // MARK: - WIDGETS ENGINE + // ========================================== + + func startWidgetsTimers() { + // 2. Media Monitor Timer (every 3 seconds) + mediaMonitorTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateMediaNowPlaying() + } + } + + // Run initial updates + updateMediaNowPlaying() + } + + func togglePomodoro() { + if pomodoroActive { + pomodoroPaused.toggle() + } else { + let durationMinutes = UserDefaults.standard.double(forKey: SettingsKey.pomodoroDurationMinutes) + let duration = (durationMinutes > 0 ? durationMinutes : 25.0) * 60.0 + pomodoroActive = true + pomodoroPaused = false + pomodoroRemaining = duration + pomodoroTotalDuration = duration + pomodoroLabel = "专注 Coding" + } + updateWidgetsSessions() + } + + func resetPomodoro() { + pomodoroActive = false + pomodoroPaused = false + let durationMinutes = UserDefaults.standard.double(forKey: SettingsKey.pomodoroDurationMinutes) + let duration = (durationMinutes > 0 ? durationMinutes : 25.0) * 60.0 + pomodoroRemaining = duration + pomodoroTotalDuration = duration + updateWidgetsSessions() + } + + func formattedRemainingTime() -> String { + let mins = Int(pomodoroRemaining) / 60 + let secs = Int(pomodoroRemaining) % 60 + return String(format: "%02d:%02d", mins, secs) + } + + private func tickPomodoro() { + guard pomodoroActive && !pomodoroPaused else { return } + if pomodoroRemaining > 1 { + pomodoroRemaining -= 1 + } else { + pomodoroRemaining = 0 + pomodoroActive = false + pomodoroPaused = false + SoundManager.shared.preview("8bit_complete") + + let notification = NSUserNotification() + notification.title = "番茄钟专注结束" + notification.informativeText = "做得好!是时候休息 5 分钟了!" + NSUserNotificationCenter.default.deliver(notification) + } + updateWidgetsSessions() + } + + + + private func updateMediaNowPlaying() { + let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) + guard handle != nil else { + Task { @MainActor in + self.mediaTrackName = "" + self.mediaArtistName = "" + self.isMediaPlaying = false + self.updateWidgetsSessions() + } + return + } + + typealias MRMediaRemoteGetNowPlayingInfoFunction = @convention(c) (DispatchQueue, @escaping (NSDictionary?) -> Void) -> Void + guard let sym = dlsym(handle, "MRMediaRemoteGetNowPlayingInfo") else { + Task { @MainActor in + self.mediaTrackName = "" + self.mediaArtistName = "" + self.isMediaPlaying = false + self.updateWidgetsSessions() + } + return + } + + let getNowPlayingInfo = unsafeBitCast(sym, to: MRMediaRemoteGetNowPlayingInfoFunction.self) + + getNowPlayingInfo(DispatchQueue.global(qos: .userInitiated)) { [weak self] info in + guard let self = self else { return } + if let info = info { + let track = info["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? "" + let artist = info["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? "" + let playbackRate = info["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 0.0 + let isPlaying = playbackRate > 0.0 + + Task { @MainActor in + self.mediaTrackName = track + self.mediaArtistName = artist + self.isMediaPlaying = isPlaying + self.updateWidgetsSessions() + } + } else { + Task { @MainActor in + self.mediaTrackName = "" + self.mediaArtistName = "" + self.isMediaPlaying = false + self.updateWidgetsSessions() + } + } + } + } + + func controlMedia(_ command: String) { + let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) + guard handle != nil else { return } + + typealias MRMediaRemoteSendCommandFunction = @convention(c) (Int32, NSDictionary?) -> Bool + guard let symCommand = dlsym(handle, "MRMediaRemoteSendCommand") else { return } + let sendCommand = unsafeBitCast(symCommand, to: MRMediaRemoteSendCommandFunction.self) + + let cmdCode: Int32 + switch command { + case "playpause": cmdCode = 2 + case "next": cmdCode = 4 + case "previous": cmdCode = 5 + default: return + } + + Task.detached(priority: .userInitiated) { + _ = sendCommand(cmdCode, nil) + } + } + + + + private var eventStore = EKEventStore() + + private func updateCalendarEvents() { + let status = EKEventStore.authorizationStatus(for: .event) + switch status { + case .authorized, .fullAccess: + fetchNextCalendarEvent() + case .notDetermined: + eventStore.requestFullAccessToEvents { [weak self] granted, _ in + if granted { + Task { @MainActor in + self?.fetchNextCalendarEvent() + } + } + } + default: + calendarEventTitle = "无日历访问权限" + calendarEventCountdown = "请在系统隐私中开启" + updateWidgetsSessions() + } + } + + private func fetchNextCalendarEvent() { + let start = Date() + let end = start.addingTimeInterval(24 * 3600) + + let predicate = eventStore.predicateForEvents(withStart: start, end: end, calendars: nil) + let events = eventStore.events(matching: predicate).sorted { $0.startDate < $1.startDate } + + if let nextEvent = events.first(where: { !$0.isAllDay && $0.startDate > start }) { + let title = nextEvent.title ?? "无标题" + let diff = Int(nextEvent.startDate.timeIntervalSince(start)) + let mins = diff / 60 + + calendarEventTitle = title + if mins < 60 { + calendarEventCountdown = "\(mins) 分钟后开始 (\(formatTime(nextEvent.startDate)))" + } else { + let hours = mins / 60 + let remMins = mins % 60 + calendarEventCountdown = "\(hours)h \(remMins)m 后开始 (\(formatTime(nextEvent.startDate)))" + } + + calendarJoinLink = extractMeetingLink(from: nextEvent) + } else { + calendarEventTitle = "无近期日程" + calendarEventCountdown = "暂无日程" + calendarJoinLink = nil + } + updateWidgetsSessions() + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + private func extractMeetingLink(from event: EKEvent) -> String? { + let fields = [event.location, event.notes, event.url?.absoluteString].compactMap { $0 } + let patterns = [ + "https://[^\\s]*zoom\\.us/j/[^\\s]*", + "https://meet\\.google\\.com/[^\\s]*", + "https://teams\\.microsoft\\.com/[^\\s]*", + "https://meeting\\.tencent\\.com/[^\\s]*" + ] + + for field in fields { + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: field, options: [], range: NSRange(location: 0, length: field.utf16.count)) { + if let range = Range(match.range, in: field) { + return String(field[range]) + } + } + } + } + return event.url?.absoluteString + } + + func updateWidgetsSessions() { + // 1. Pomodoro Focus Session - Bypassed + let pomodoroId = "pomodoro" + sessions.removeValue(forKey: pomodoroId) + + // 2. Media Controls Session + let mediaId = "media" + let isMediaEnabled = UserDefaults.standard.bool(forKey: SettingsKey.mediaControllerEnabled) + if isMediaEnabled && !mediaTrackName.isEmpty { + if sessions[mediaId] == nil { + var snap = SessionSnapshot() + snap.source = "media" + snap.cwd = "/" + snap.currentTool = "媒体控制" + sessions[mediaId] = snap + } + sessions[mediaId]?.status = isMediaPlaying ? .processing : .idle + sessions[mediaId]?.toolDescription = isMediaPlaying ? "正在播放: \(mediaTrackName) - \(mediaArtistName)" : "已暂停" + sessions[mediaId]?.lastActivity = Date() + } else { + sessions.removeValue(forKey: mediaId) + } + + // 5. Calendar Session - Bypassed + let calendarId = "calendar" + sessions.removeValue(forKey: calendarId) + + refreshDerivedState() + } } /// Encode a path the same way Claude Code does for project directory names: diff --git a/Sources/CodeIsland/BuddyView.swift b/Sources/UniIsland/BuddyView.swift similarity index 99% rename from Sources/CodeIsland/BuddyView.swift rename to Sources/UniIsland/BuddyView.swift index 7c7ff79c..1cbdc370 100644 --- a/Sources/CodeIsland/BuddyView.swift +++ b/Sources/UniIsland/BuddyView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// Buddy — CodeBuddy mascot, pixel-art cat astronaut. /// Purple #6C4DFF body with cyan-green #32E6B9 accents. Tencent Cloud style. diff --git a/Sources/CodeIsland/BundleExtension.swift b/Sources/UniIsland/BundleExtension.swift similarity index 92% rename from Sources/CodeIsland/BundleExtension.swift rename to Sources/UniIsland/BundleExtension.swift index b5f6fc30..b4a6f2fb 100644 --- a/Sources/CodeIsland/BundleExtension.swift +++ b/Sources/UniIsland/BundleExtension.swift @@ -4,7 +4,7 @@ extension Bundle { /// Custom bundle accessor that finds the SPM resource bundle in Contents/Resources/ /// when running as a signed .app bundle, falling back to Bundle.module for dev builds. static let appModule: Bundle = { - let bundleName = "CodeIsland_CodeIsland" + let bundleName = "UniIsland_UniIsland" // .app bundle: Contents/Resources/.bundle if let resourceURL = Bundle.main.resourceURL, diff --git a/Sources/UniIsland/CatView.swift b/Sources/UniIsland/CatView.swift new file mode 100644 index 00000000..afa8a063 --- /dev/null +++ b/Sources/UniIsland/CatView.swift @@ -0,0 +1,609 @@ +import SwiftUI +import UniIslandCore + +/// CatView — Extremely cute 8-bit pixel-art animated kitten mascot. +/// Features detailed orange tabby cat sprites for sleep (idle), play/work (active), and alert (waiting) states. +struct CatView: View { + let status: AgentStatus + var size: CGFloat = 27 + var isDraggingOver: Bool = false + var isCoding: Bool = false + @State private var alive = false + @Environment(\.mascotSpeed) private var speed + + // Tabby Kitten Palette + private static let catOrange = Color(red: 0.95, green: 0.55, blue: 0.15) // Main Orange + private static let catLight = Color(red: 1.00, green: 0.78, blue: 0.45) // Cream Highlight + private static let catDark = Color(red: 0.78, green: 0.38, blue: 0.05) // Tabby Stripe / Shadow + private static let earPink = Color(red: 1.00, green: 0.70, blue: 0.70) // Pink Ear Inner / Nose + private static let eyeC = Color(red: 0.15, green: 0.10, blue: 0.05) // Dark Brown Eyes + private static let toyC = Color(red: 0.20, green: 0.75, blue: 1.00) // Blue yarn/toy ball + + var body: some View { + ZStack { + if isDraggingOver { + dropsScene + } else if isCoding { + codingScene + } else { + switch status { + case .idle: sleepScene + case .processing, .running: workScene + case .waitingApproval, .waitingQuestion: alertScene + } + } + } + .frame(width: size, height: size) + .clipped() + .onAppear { alive = true } + .onChange(of: status) { + alive = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { alive = true } + } + } + + private struct V { + let ox: CGFloat, oy: CGFloat, s: CGFloat, y0: CGFloat + init(_ sz: CGSize, svgW: CGFloat = 16, svgH: CGFloat = 16, svgY0: CGFloat = 2) { + s = min(sz.width / svgW, sz.height / svgH) + ox = (sz.width - svgW * s) / 2 + oy = (sz.height - svgH * s) / 2 + y0 = svgY0 + } + func r(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat, dy: CGFloat = 0) -> CGRect { + CGRect(x: ox + x * s, y: oy + (y - y0 + dy) * s, width: w * s, height: h * s) + } + func path(_ points: [(CGFloat, CGFloat)], dy: CGFloat = 0) -> Path { + var p = Path() + guard let first = points.first else { return p } + p.move(to: CGPoint(x: ox + first.0 * s, y: oy + (first.1 - y0 + dy) * s)) + for i in 1.. CGFloat { + guard let first = keyframes.first else { return 0 } + let firstPct = CGFloat(first.0.doubleValue) + if pct <= firstPct { return first.1 } + for i in 1.. some View { + ZStack { + ForEach(0..<3, id: \.self) { i in + let ci = Double(i) + let cycle = 2.8 + ci * 0.3 + let delay = ci * 0.9 + let phase = max(0, ((t - delay).truncatingRemainder(dividingBy: cycle)) / cycle) + let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) + let baseOp = 0.65 - ci * 0.1 + let opacity = phase < 0.8 ? baseOp : (1.0 - phase) * 3.5 * baseOp + let xOff = size * CGFloat(0.18 + ci * 0.08 + sin(phase * .pi * 2) * 0.03) + let yOff = -size * CGFloat(0.12 + phase * 0.38) + Text("z") + .font(.system(size: fontSize, weight: .black, design: .monospaced)) + .foregroundStyle(.white.opacity(opacity)) + .offset(x: xOff, y: yOff) + } + } + } + + private func sleepCanvas(t: Double) -> some View { + let breathe = max(0, sin(t * 2.0)) * 0.4 // dozing nod off + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 16, svgY0: 2) + let nodY = breathe + + // Shadow + let shadowW: CGFloat = 10.0 + c.fill(Path(v.r(8 - shadowW / 2, 14.5, shadowW, 1)), + with: .color(.black.opacity(0.18))) + + // Sitting Cat Body + let bodyW: CGFloat = 7.0 + let bodyH: CGFloat = 5.8 + let bodyX: CGFloat = 8.0 - bodyW / 2 + let bodyY: CGFloat = 14.5 - bodyH + c.fill(Path(roundedRect: v.r(bodyX, bodyY, bodyW, bodyH), cornerRadius: 2.0 * v.s), with: .color(Self.catOrange)) + + // Cream chest + c.fill(Path(roundedRect: v.r(bodyX + 1.2, bodyY + 1.8, bodyW - 2.4, 3.5), cornerRadius: 1.0 * v.s), with: .color(Self.catLight)) + + // Stripes on body sides + c.fill(Path(v.r(bodyX, bodyY + 1.2, 0.8, 1.5)), with: .color(Self.catDark)) + c.fill(Path(v.r(bodyX + bodyW - 0.8, bodyY + 1.2, 0.8, 1.5)), with: .color(Self.catDark)) + + // Resting paws + c.fill(Path(ellipseIn: v.r(bodyX + 1.5, bodyY + 4.2, 1.2, 1.2)), with: .color(Self.catLight)) + c.fill(Path(ellipseIn: v.r(bodyX + bodyW - 2.7, bodyY + 4.2, 1.2, 1.2)), with: .color(Self.catLight)) + + // Dozing Head (nods down slowly) + let headW: CGFloat = 7.8 + let headH: CGFloat = 5.8 + let headX: CGFloat = 8.0 - headW / 2 + let headY: CGFloat = bodyY - headH + 1.5 + nodY + c.fill(Path(roundedRect: v.r(headX, headY, headW, headH), cornerRadius: 2.4 * v.s), with: .color(Self.catOrange)) + + // Cream muzzle/snout + c.fill(Path(roundedRect: v.r(headX + 1.9, headY + 3.3, 4.0, 2.0), cornerRadius: 1.0 * v.s), with: .color(Self.catLight)) + + // Ears + let leftEar: [(CGFloat, CGFloat)] = [(headX + 0.5, headY + 1.5), (headX + 0.5, headY - 1.2), (headX + 3.0, headY + 0.8)] + let rightEar: [(CGFloat, CGFloat)] = [(headX + 4.8, headY + 0.8), (headX + 7.3, headY - 1.2), (headX + 7.3, headY + 1.5)] + c.fill(v.path(leftEar), with: .color(Self.catDark)) + c.fill(v.path(rightEar), with: .color(Self.catDark)) + + // Inner Ears (Pink) + c.fill(v.path([(headX + 0.8, headY + 1.0), (headX + 0.8, headY - 0.5), (headX + 2.5, headY + 0.8)]), with: .color(Self.earPink)) + c.fill(v.path([(headX + 5.3, headY + 0.8), (headX + 7.0, headY - 0.5), (headX + 7.0, headY + 1.0)]), with: .color(Self.earPink)) + + // Whiskers (Left & Right) + // Left whiskers + var wl1 = Path() + wl1.move(to: CGPoint(x: v.ox + (headX - 1.5) * v.s, y: v.oy + (headY + 3.6 - v.y0) * v.s)) + wl1.addLine(to: CGPoint(x: v.ox + (headX + 0.5) * v.s, y: v.oy + (headY + 3.6 - v.y0) * v.s)) + c.stroke(wl1, with: .color(Self.catDark), lineWidth: 0.8) + + var wl2 = Path() + wl2.move(to: CGPoint(x: v.ox + (headX - 1.5) * v.s, y: v.oy + (headY + 4.3 - v.y0) * v.s)) + wl2.addLine(to: CGPoint(x: v.ox + (headX + 0.5) * v.s, y: v.oy + (headY + 4.0 - v.y0) * v.s)) + c.stroke(wl2, with: .color(Self.catDark), lineWidth: 0.8) + + // Right whiskers + var wr1 = Path() + wr1.move(to: CGPoint(x: v.ox + (headX + headW - 0.5) * v.s, y: v.oy + (headY + 3.6 - v.y0) * v.s)) + wr1.addLine(to: CGPoint(x: v.ox + (headX + headW + 1.5) * v.s, y: v.oy + (headY + 3.6 - v.y0) * v.s)) + c.stroke(wr1, with: .color(Self.catDark), lineWidth: 0.8) + + var wr2 = Path() + wr2.move(to: CGPoint(x: v.ox + (headX + headW - 0.5) * v.s, y: v.oy + (headY + 4.0 - v.y0) * v.s)) + wr2.addLine(to: CGPoint(x: v.ox + (headX + headW + 1.5) * v.s, y: v.oy + (headY + 4.3 - v.y0) * v.s)) + c.stroke(wr2, with: .color(Self.catDark), lineWidth: 0.8) + + // Sleeping closed eyes + c.fill(Path(v.r(headX + 1.5, headY + 2.5, 1.2, 0.6)), with: .color(Self.eyeC)) + c.fill(Path(v.r(headX + 5.1, headY + 2.5, 1.2, 0.6)), with: .color(Self.eyeC)) + + // Pink nose + c.fill(Path(v.r(headX + 3.5, headY + 3.2, 0.8, 0.6)), with: .color(Self.earPink)) + + // Little w-shaped mouth + c.stroke(v.path([(headX + 3.1, headY + 3.9), (headX + 3.5, headY + 4.2), (headX + 3.9, headY + 3.9), (headX + 4.3, headY + 4.2), (headX + 4.7, headY + 3.9)]), with: .color(Self.catDark), lineWidth: 0.8) + + // Curled tail wiggling slowly, with a cream-colored tip + let tailWave = sin(t * 1.5) * 0.6 + let tailPoints: [(CGFloat, CGFloat)] = [ + (bodyX + bodyW - 1.0, bodyY + bodyH - 1.0), + (bodyX + bodyW + 1.5, bodyY + bodyH - 2.0 + tailWave), + (bodyX + bodyW + 1.0, bodyY + bodyH - 3.5 + tailWave), + (bodyX + bodyW - 0.5, bodyY + bodyH - 3.0 + tailWave) + ] + c.fill(v.path(tailPoints), with: .color(Self.catOrange)) + c.fill(Path(ellipseIn: v.r(bodyX + bodyW + 0.6, bodyY + bodyH - 3.6 + tailWave, 1.2, 1.2)), with: .color(Self.catLight)) + } + } + + // ━━━━━━ PLAY/WORK (Active State) ━━━━━━ + private var workScene: some View { + TimelineView(.periodic(from: .now, by: 0.04)) { ctx in + workCanvas(t: ctx.date.timeIntervalSinceReferenceDate * speed) + } + } + + private func workCanvas(t: Double) -> some View { + let bounce = sin(t * 2 * .pi / 0.45) * 0.8 + let tailWave = sin(t * 2 * .pi / 0.6) * 1.5 + let blinkCycle = t.truncatingRemainder(dividingBy: 4.5) + let isBlinking = blinkCycle > 4.2 && blinkCycle < 4.4 + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 16, svgY0: 2) + let dy = bounce + + // Shadow + let shadowW: CGFloat = 11 - abs(dy) * 0.2 + c.fill(Path(v.r(8 - shadowW / 2, 14.5, shadowW, 1)), + with: .color(.black.opacity(max(0.1, 0.25 - abs(dy) * 0.03)))) + + // Toy yarn ball (rolling/spinning at the bottom right) + let toyRect = v.r(11.5, 11.5 + dy * 0.2, 3.5, 3.5) + c.fill(Path(ellipseIn: toyRect), with: .color(Self.toyC)) + // Thread lines + c.stroke(Path(v.r(11.5, 13.0 + dy * 0.2, 3.5, 0.5)), with: .color(.white.opacity(0.5)), lineWidth: 0.5) + + // Cat sitting/playing body + let bodyRect = v.r(2.5, 8.5 + dy, 7.5, 6.0) + c.fill(Path(roundedRect: bodyRect, cornerRadius: 2.0 * v.s), with: .color(Self.catOrange)) + + // White chest + c.fill(Path(roundedRect: v.r(4.0, 11.0 + dy, 4.5, 3.5), cornerRadius: 1.0 * v.s), with: .color(Self.catLight)) + + // Stripes + c.fill(Path(v.r(4.0, 9.0 + dy, 0.8, 2.0)), with: .color(Self.catDark)) + c.fill(Path(v.r(7.5, 9.0 + dy, 0.8, 2.0)), with: .color(Self.catDark)) + + // Head (sitting high) + let headX: CGFloat = 3.0 + let headY: CGFloat = 3.5 + dy + let headRect = v.r(headX, headY, 6.5, 5.5) + c.fill(Path(roundedRect: headRect, cornerRadius: 2.2 * v.s), with: .color(Self.catOrange)) + + // Ears + let leftEar: [(CGFloat, CGFloat)] = [(headX + 0.5, headY + 1.0), (headX + 0.5, headY - 1.2), (headX + 3.0, headY + 0.5)] + let rightEar: [(CGFloat, CGFloat)] = [(headX + 3.5, headY + 0.5), (headX + 6.0, headY - 1.2), (headX + 6.0, headY + 1.0)] + c.fill(v.path(leftEar), with: .color(Self.catDark)) + c.fill(v.path(rightEar), with: .color(Self.catDark)) + c.fill(v.path([(headX + 0.8, headY + 0.8), (headX + 0.8, headY - 0.5), (headX + 2.5, headY + 0.5)]), with: .color(Self.earPink)) + c.fill(v.path([(headX + 4.0, headY + 0.5), (headX + 5.7, headY - 0.5), (headX + 5.7, headY + 0.8)]), with: .color(Self.earPink)) + + // Blinking eyes + if isBlinking { + c.fill(Path(v.r(headX + 1.3, headY + 2.7, 1.2, 0.3)), with: .color(Self.eyeC)) + c.fill(Path(v.r(headX + 4.0, headY + 2.7, 1.2, 0.3)), with: .color(Self.eyeC)) + } else { + c.fill(Path(v.r(headX + 1.3, headY + 2.1, 1.2, 1.6)), with: .color(Self.eyeC)) + c.fill(Path(v.r(headX + 4.0, headY + 2.1, 1.2, 1.6)), with: .color(Self.eyeC)) + // Cute eye shine + c.fill(Path(v.r(headX + 1.3, headY + 2.1, 0.6, 0.6)), with: .color(.white)) + c.fill(Path(v.r(headX + 4.0, headY + 2.1, 0.6, 0.6)), with: .color(.white)) + } + + // Nose + c.fill(Path(v.r(headX + 3.0, headY + 3.2, 0.8, 0.6)), with: .color(Self.earPink)) + + // Waving tail + let tailPoints: [(CGFloat, CGFloat)] = [ + (2.5, 12.0 + dy), + (1.0 + tailWave * 0.2, 10.0 + tailWave), + (0.0 + tailWave * 0.3, 8.5 + tailWave * 1.2), + (1.0 + tailWave * 0.2, 9.0 + tailWave) + ] + c.fill(v.path(tailPoints), with: .color(Self.catOrange)) + + // Patting paw (reaches toward yarn ball) + let pawReach = sin(t * 2 * .pi / 0.45) * 1.5 + let pawRect = v.r(8.5 + pawReach * 0.6, 11.5 + dy + abs(pawReach) * 0.3, 1.6, 1.6) + c.fill(Path(ellipseIn: pawRect), with: .color(Self.catOrange)) + } + } + + // ━━━━━━ ALERT (Notification State) ━━━━━━ + private var alertScene: some View { + ZStack { + Circle() + .fill(Self.toyC.opacity(alive ? 0.12 : 0)) + .frame(width: size * 0.85) + .blur(radius: size * 0.05) + .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: alive) + + TimelineView(.periodic(from: .now, by: 0.03)) { ctx in + alertCanvas(t: ctx.date.timeIntervalSinceReferenceDate * speed) + } + } + } + + private func alertCanvas(t: Double) -> some View { + let cycle = t.truncatingRemainder(dividingBy: 3.2) + let pct = cycle / 3.2 + + let num0 = NSDecimalNumber(decimal: 0) + let num03 = NSDecimalNumber(decimal: 0.03) + let num10 = NSDecimalNumber(decimal: 0.10) + let num15 = NSDecimalNumber(decimal: 0.15) + let num175 = NSDecimalNumber(decimal: 0.175) + let num20 = NSDecimalNumber(decimal: 0.20) + let num25 = NSDecimalNumber(decimal: 0.25) + let num275 = NSDecimalNumber(decimal: 0.275) + let num30 = NSDecimalNumber(decimal: 0.30) + let num35 = NSDecimalNumber(decimal: 0.35) + let num375 = NSDecimalNumber(decimal: 0.375) + let num40 = NSDecimalNumber(decimal: 0.40) + let num45 = NSDecimalNumber(decimal: 0.45) + let num475 = NSDecimalNumber(decimal: 0.475) + let num50 = NSDecimalNumber(decimal: 0.50) + let num55 = NSDecimalNumber(decimal: 0.55) + let num62 = NSDecimalNumber(decimal: 0.62) + let num1 = NSDecimalNumber(decimal: 1.0) + + let jumpY = lerp([ + (num0, 0), (num03, 0), (num10, -0.8), (num15, 1.2), + (num175, -8.0), (num20, -8.0), (num25, 1.2), + (num275, -6.0), (num30, -6.0), (num35, 0.8), + (num375, -3.8), (num40, -3.8), (num45, 0.6), + (num475, -2.0), (num50, -2.0), (num55, 0.2), + (num62, 0), (num1, 0), + ], at: pct) + + let shakeX: CGFloat = (pct > 0.15 && pct < 0.55) ? sin(pct * 90) * 0.6 : 0 + let scale: CGFloat = (pct > 0.03 && pct < 0.55) ? 1.0 + sin(pct * 25) * 0.15 : 1.0 + + let bangOp = lerp([ + (num0, 0), (num03, 1), (num10, 1), (num55, 1), (num62, 0), (num1, 0), + ], at: pct) + let bangScale = lerp([ + (num0, 0.3), (num03, 1.3), (num10, 1.0), (num55, 1.0), (num62, 0.6), (num1, 0.6), + ], at: pct) + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 16, svgY0: 2) + + // Shadow + let shadowW: CGFloat = 11 * (1.0 - abs(min(0, jumpY)) * 0.05) + c.fill(Path(v.r(8 - shadowW / 2, 14.5, shadowW, 1)), + with: .color(.black.opacity(max(0.08, 0.30 - abs(min(0, jumpY)) * 0.04)))) + + c.translateBy(x: shakeX * v.s, y: 0) + + // Startled cat body + let bodyW = 8.5 * scale + let bodyH = 5.5 * scale + let bodyX = 8.0 - bodyW / 2 + let bodyY = 14.5 - bodyH + jumpY + c.fill(Path(roundedRect: v.r(bodyX, bodyY, bodyW, bodyH), cornerRadius: 2.0 * v.s * scale), with: .color(Self.catOrange)) + + // Stripes + c.fill(Path(v.r(bodyX + 2, bodyY, 0.8, 2)), with: .color(Self.catDark)) + c.fill(Path(v.r(bodyX + 5, bodyY, 0.8, 2)), with: .color(Self.catDark)) + + // Startled Head (jumping high) + let headW = 7.0 * scale + let headH = 6.0 * scale + let headX = 8.0 - headW / 2 + let headY = bodyY - headH + 1.2 + c.fill(Path(roundedRect: v.r(headX, headY, headW, headH), cornerRadius: 2.2 * v.s * scale), with: .color(Self.catOrange)) + + // Alert Ears standing straight up! + let leftEar: [(CGFloat, CGFloat)] = [(headX + 0.5, headY + 1.5), (headX + 0.5, headY - 1.8), (headX + 3.2, headY + 0.8)] + let rightEar: [(CGFloat, CGFloat)] = [(headX + 3.8, headY + 0.8), (headX + 6.5, headY - 1.8), (headX + 6.5, headY + 1.5)] + c.fill(v.path(leftEar), with: .color(Self.catDark)) + c.fill(v.path(rightEar), with: .color(Self.catDark)) + c.fill(v.path([(headX + 0.8, headY + 1.0), (headX + 0.8, headY - 0.8), (headX + 2.8, headY + 0.8)]), with: .color(Self.earPink)) + c.fill(v.path([(headX + 4.2, headY + 0.8), (headX + 6.2, headY - 0.8), (headX + 6.2, headY + 1.0)]), with: .color(Self.earPink)) + + // Wide open alert eyes! + c.fill(Path(ellipseIn: v.r(headX + 1.2, headY + 2.0, 1.8, 1.8)), with: .color(Self.eyeC)) + c.fill(Path(ellipseIn: v.r(headX + 4.0, headY + 2.0, 1.8, 1.8)), with: .color(Self.eyeC)) + // Big white pupils + c.fill(Path(ellipseIn: v.r(headX + 1.5, headY + 2.3, 0.8, 0.8)), with: .color(.white)) + c.fill(Path(ellipseIn: v.r(headX + 4.3, headY + 2.3, 0.8, 0.8)), with: .color(.white)) + + // Nose + c.fill(Path(v.r(headX + 3.1, headY + 3.5, 0.8, 0.6)), with: .color(Self.earPink)) + + // Startled tail standing straight up! + let tailPoints: [(CGFloat, CGFloat)] = [ + (bodyX + 1.0, bodyY + 2.0), + (bodyX - 1.2, bodyY - 3.5), + (bodyX - 0.2, bodyY - 4.5), + (bodyX + 1.8, bodyY + 1.0) + ] + c.fill(v.path(tailPoints), with: .color(Self.catOrange)) + + // Exclamation mark (!) + if bangOp > 0.01 { + let bw: CGFloat = 1.6 * bangScale + let bx: CGFloat = 12.8 + let by: CGFloat = 2.5 + jumpY * 0.2 + c.fill(Path(v.r(bx, by, bw, 3.2 * bangScale)), with: .color(Self.toyC.opacity(bangOp))) + c.fill(Path(v.r(bx, by + 3.8 * bangScale, bw, 1.2 * bangScale)), with: .color(Self.toyC.opacity(bangOp))) + } + } + } + + // ━━━━━━ DROPS (Dragging Over State) ━━━━━━ + private var dropsScene: some View { + TimelineView(.periodic(from: .now, by: 0.05)) { ctx in + dropsCanvas(t: ctx.date.timeIntervalSinceReferenceDate * speed) + } + } + + private func dropsCanvas(t: Double) -> some View { + let bounce = sin(t * 6.0) * 0.4 + let tailWave = sin(t * 8.0) * 1.8 + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 16, svgY0: 2) + let dy = bounce + + // Shadow + let shadowW: CGFloat = 10.5 - abs(dy) * 0.2 + c.fill(Path(v.r(8 - shadowW / 2, 14.5, shadowW, 1)), + with: .color(.black.opacity(0.20 - abs(dy) * 0.02))) + + // Leaned back sitting body + let bodyW: CGFloat = 8.0 + let bodyH: CGFloat = 5.5 + let bodyX: CGFloat = 8 - bodyW / 2 + let bodyY: CGFloat = 14.5 - bodyH + dy + c.fill(Path(roundedRect: v.r(bodyX, bodyY, bodyW, bodyH), cornerRadius: 2.0 * v.s), with: .color(Self.catOrange)) + + // Cream chest + c.fill(Path(roundedRect: v.r(bodyX + 1.5, bodyY + 2.5, bodyW - 3.0, 3.0), cornerRadius: 1.0 * v.s), with: .color(Self.catLight)) + + // Stripes on body + c.fill(Path(v.r(bodyX + 1.0, bodyY + 1.0, 0.8, 1.8)), with: .color(Self.catDark)) + c.fill(Path(v.r(bodyX + 6.2, bodyY + 1.0, 0.8, 1.8)), with: .color(Self.catDark)) + + // Head looking up + let headW: CGFloat = 7.0 + let headH: CGFloat = 5.8 + let headX: CGFloat = 8 - headW / 2 + let headY: CGFloat = bodyY - headH + 1.5 + c.fill(Path(roundedRect: v.r(headX, headY, headW, headH), cornerRadius: 2.2 * v.s), with: .color(Self.catOrange)) + + // Pointy ears looking alert + let leftEar: [(CGFloat, CGFloat)] = [(headX + 0.5, headY + 1.5), (headX + 0.2, headY - 1.5), (headX + 3.0, headY + 0.8)] + let rightEar: [(CGFloat, CGFloat)] = [(headX + 4.0, headY + 0.8), (headX + 6.8, headY - 1.5), (headX + 6.5, headY + 1.5)] + c.fill(v.path(leftEar), with: .color(Self.catDark)) + c.fill(v.path(rightEar), with: .color(Self.catDark)) + + // Pink inside ears + c.fill(v.path([(headX + 0.8, headY + 1.1), (headX + 0.5, headY - 0.7), (headX + 2.6, headY + 0.8)]), with: .color(Self.earPink)) + c.fill(v.path([(headX + 4.4, headY + 0.8), (headX + 6.5, headY - 0.7), (headX + 6.2, headY + 1.1)]), with: .color(Self.earPink)) + + // Wide open sparkly eyes looking up! + let eyeY = headY + 1.5 + c.fill(Path(ellipseIn: v.r(headX + 1.2, eyeY, 1.8, 1.8)), with: .color(Self.eyeC)) + c.fill(Path(ellipseIn: v.r(headX + 4.0, eyeY, 1.8, 1.8)), with: .color(Self.eyeC)) + + // Big white sparkles near the top-center of the eyes + c.fill(Path(ellipseIn: v.r(headX + 1.5, eyeY + 0.2, 0.8, 0.8)), with: .color(.white)) + c.fill(Path(ellipseIn: v.r(headX + 4.3, eyeY + 0.2, 0.8, 0.8)), with: .color(.white)) + // Extra tiny sparkle + c.fill(Path(ellipseIn: v.r(headX + 2.2, eyeY + 1.0, 0.4, 0.4)), with: .color(.white)) + c.fill(Path(ellipseIn: v.r(headX + 5.0, eyeY + 1.0, 0.4, 0.4)), with: .color(.white)) + + // Pink nose + c.fill(Path(v.r(headX + 3.1, headY + 3.2, 0.8, 0.6)), with: .color(Self.earPink)) + + // Startled/happy open mouth + c.fill(Path(ellipseIn: v.r(headX + 3.0, headY + 4.0, 1.0, 1.1)), with: .color(Self.earPink)) + + // Waving excited tail + let tailPoints: [(CGFloat, CGFloat)] = [ + (bodyX + 1.5, bodyY + 3.5), + (bodyX - 1.5 + tailWave * 0.2, bodyY + 1.5 + tailWave), + (bodyX - 2.5 + tailWave * 0.3, bodyY - 0.5 + tailWave * 1.2), + (bodyX - 1.5 + tailWave * 0.2, bodyY + 0.2 + tailWave) + ] + c.fill(v.path(tailPoints), with: .color(Self.catOrange)) + + // Open paws reaching UPwards to catch the file! + let pawL_Y = headY + 3.0 + sin(t * 8.0) * 0.6 + let pawR_Y = headY + 3.0 + sin(t * 8.0 + .pi) * 0.6 + c.fill(Path(roundedRect: v.r(headX - 1.2, pawL_Y, 1.6, 2.2), cornerRadius: 0.8 * v.s), with: .color(Self.catOrange)) + c.fill(Path(roundedRect: v.r(headX + headW - 0.4, pawR_Y, 1.6, 2.2), cornerRadius: 0.8 * v.s), with: .color(Self.catOrange)) + + // Cream paw pads on the raised paws + c.fill(Path(ellipseIn: v.r(headX - 1.0, pawL_Y + 0.3, 1.2, 1.2)), with: .color(Self.catLight)) + c.fill(Path(ellipseIn: v.r(headX + headW - 0.2, pawR_Y + 0.3, 1.2, 1.2)), with: .color(Self.catLight)) + } + } + + // ━━━━━━ CODING (Pomodoro Active State) ━━━━━━ + private var codingScene: some View { + TimelineView(.periodic(from: .now, by: 0.04)) { ctx in + codingCanvas(t: ctx.date.timeIntervalSinceReferenceDate * speed) + } + } + + private func codingCanvas(t: Double) -> some View { + let bounce = sin(t * 8.0) * 0.4 + let tailWave = sin(t * 4.0) * 1.0 + let dy = bounce + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 16, svgY0: 2) + + // Shadow under cat and laptop + let shadowW: CGFloat = 11.0 + c.fill(Path(v.r(8 - shadowW / 2, 14.5, shadowW, 1)), + with: .color(.black.opacity(0.18))) + + // Draw a cute glowing laptop on the left + let screenGlow = Color(red: 0.20, green: 0.85, blue: 1.00) + // Laptop screen (tilted / open) + c.stroke(v.path([(2.0, 13.0 + dy), (1.5, 9.0 + dy)]), with: .color(Self.catDark), lineWidth: 1.5) + // Glowing screen panel + c.fill(v.path([(2.2, 12.8 + dy), (1.8, 9.2 + dy), (3.0, 9.5 + dy), (3.0, 12.8 + dy)]), with: .color(screenGlow.opacity(0.85))) + + // Laptop keyboard base + c.fill(Path(v.r(2.0, 13.0 + dy, 4.0, 1.0)), with: .color(Self.catDark)) + // Key highlights + c.fill(Path(v.r(2.5, 13.2 + dy, 2.5, 0.5)), with: .color(.white.opacity(0.4))) + + // Floating code particles (0 and 1 dots) rising from screen + for i in 0..<3 { + let pCycle = 3.0 + let pDelay = Double(i) * 1.0 + let pPhase = max(0, ((t + pDelay).truncatingRemainder(dividingBy: pCycle)) / pCycle) + let px = 2.5 + CGFloat(sin(t * 2.0 + Double(i))) * 0.8 + let py = 8.5 - CGFloat(pPhase * 6.0) + dy + let popacity = 1.0 - pPhase + if popacity > 0.05 { + c.fill(Path(v.r(px, py, 0.8, 0.8)), with: .color(screenGlow.opacity(popacity))) + } + } + + // Cat sitting body (facing left towards laptop) + let bodyX: CGFloat = 6.0 + let bodyY: CGFloat = 14.5 - 6.0 + dy + let bodyW: CGFloat = 7.0 + let bodyH: CGFloat = 6.0 + c.fill(Path(roundedRect: v.r(bodyX, bodyY, bodyW, bodyH), cornerRadius: 2.0 * v.s), with: .color(Self.catOrange)) + + // Cream chest + c.fill(Path(roundedRect: v.r(bodyX, bodyY + 2.5, 2.5, 3.5), cornerRadius: 1.0 * v.s), with: .color(Self.catLight)) + + // Stripes on back + c.fill(Path(v.r(bodyX + 5.0, bodyY + 1.0, 0.8, 1.8)), with: .color(Self.catDark)) + c.fill(Path(v.r(bodyX + 6.0, bodyY + 2.0, 0.8, 1.8)), with: .color(Self.catDark)) + + // Head (facing left) + let headX: CGFloat = 5.0 + let headY: CGFloat = bodyY - 5.0 + 1.2 + c.fill(Path(roundedRect: v.r(headX, headY, 6.5, 5.0), cornerRadius: 2.2 * v.s), with: .color(Self.catOrange)) + + // Pointy pink ears + c.fill(v.path([(headX + 1.5, headY + 0.8), (headX + 2.5, headY - 1.2), (headX + 3.5, headY + 0.5)]), with: .color(Self.catDark)) + c.fill(v.path([(headX + 4.5, headY + 0.5), (headX + 5.5, headY - 1.2), (headX + 6.0, headY + 0.8)]), with: .color(Self.catDark)) + c.fill(v.path([(headX + 1.8, headY + 0.6), (headX + 2.5, headY - 0.6), (headX + 3.2, headY + 0.5)]), with: .color(Self.earPink)) + c.fill(v.path([(headX + 4.8, headY + 0.5), (headX + 5.5, headY - 0.6), (headX + 5.8, headY + 0.6)]), with: .color(Self.earPink)) + + // Focused eye looking left + c.fill(Path(v.r(headX + 1.0, headY + 2.0, 1.5, 1.5)), with: .color(Self.eyeC)) + c.fill(Path(v.r(headX + 1.0, headY + 2.0, 0.6, 0.6)), with: .color(.white)) // eye shine + + // Whiskers (left side, tiny thin lines) + c.stroke(v.path([(headX + 0.5, headY + 3.2), (headX - 0.5, headY + 3.0)]), with: .color(Self.catDark), lineWidth: 0.5) + c.stroke(v.path([(headX + 0.5, headY + 3.7), (headX - 0.5, headY + 3.9)]), with: .color(Self.catDark), lineWidth: 0.5) + + // Nose + c.fill(Path(v.r(headX + 0.6, headY + 3.1, 0.5, 0.5)), with: .color(Self.earPink)) + + // Typing Paws (moving rapidly left and right onto the keyboard!) + let paw1X = 4.0 + CGFloat(sin(t * 15.0)) * 0.6 + let paw1Y = 12.2 + CGFloat(cos(t * 15.0)) * 0.4 + dy + c.fill(Path(ellipseIn: v.r(paw1X, paw1Y, 1.2, 1.2)), with: .color(Self.catOrange)) + + let paw2X = 4.3 - CGFloat(sin(t * 15.0)) * 0.6 + let paw2Y = 12.6 - CGFloat(cos(t * 15.0)) * 0.4 + dy + c.fill(Path(ellipseIn: v.r(paw2X, paw2Y, 1.2, 1.2)), with: .color(Self.catOrange)) + + // Wiggling Tail Curled Behind + let tailPoints: [(CGFloat, CGFloat)] = [ + (bodyX + bodyW - 1.0, bodyY + bodyH - 1.0), + (bodyX + bodyW + 1.5, bodyY + bodyH - 2.0 + tailWave), + (bodyX + bodyW + 1.0, bodyY + bodyH - 3.5 + tailWave), + (bodyX + bodyW - 0.5, bodyY + bodyH - 3.0 + tailWave) + ] + c.fill(v.path(tailPoints), with: .color(Self.catOrange)) + c.fill(Path(ellipseIn: v.r(bodyX + bodyW + 0.6, bodyY + bodyH - 3.6 + tailWave, 1.2, 1.2)), with: .color(Self.catLight)) + } + } +} diff --git a/Sources/CodeIsland/ClineView.swift b/Sources/UniIsland/ClineView.swift similarity index 99% rename from Sources/CodeIsland/ClineView.swift rename to Sources/UniIsland/ClineView.swift index 50123fc6..900b9275 100644 --- a/Sources/CodeIsland/ClineView.swift +++ b/Sources/UniIsland/ClineView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// ClineBot — Cline (saoudrizwan.claude-dev) VSCode extension mascot. /// A compact green robot with a wrench, echoing Cline's tool-use identity. diff --git a/Sources/CodeIsland/CodexPermissionRules.swift b/Sources/UniIsland/CodexPermissionRules.swift similarity index 97% rename from Sources/CodeIsland/CodexPermissionRules.swift rename to Sources/UniIsland/CodexPermissionRules.swift index f6df4fd0..95ebee67 100644 --- a/Sources/CodeIsland/CodexPermissionRules.swift +++ b/Sources/UniIsland/CodexPermissionRules.swift @@ -1,5 +1,5 @@ import Foundation -import CodeIslandCore +import UniIslandCore struct CodexPermissionRules { private let fileManager: FileManager @@ -90,7 +90,7 @@ struct CodexPermissionRules { } let rulesDirectory = ConfigInstaller.codexHome() + "/rules" - let rulesPath = rulesDirectory + "/codeisland.rules" + let rulesPath = rulesDirectory + "/uniisland.rules" let block = Self.ruleBlock(for: pattern) let patternLine = Self.patternLine(for: pattern) @@ -117,11 +117,11 @@ struct CodexPermissionRules { private static func ruleBlock(for pattern: [String]) -> String { """ - # Added by CodeIsland when "Always Allow" is clicked for Codex. + # Added by UniIsland when "Always Allow" is clicked for Codex. prefix_rule( \(patternLine(for: pattern)), decision = "allow", - justification = "Allowed from CodeIsland Always Allow", + justification = "Allowed from UniIsland Always Allow", ) """ diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/UniIsland/ConfigInstaller.swift similarity index 96% rename from Sources/CodeIsland/ConfigInstaller.swift rename to Sources/UniIsland/ConfigInstaller.swift index 728566af..7708e39f 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/UniIsland/ConfigInstaller.swift @@ -1,11 +1,11 @@ import Foundation -import CodeIslandCore +import UniIslandCore import Yams // MARK: - Hook Identifiers private enum HookId { - static let current = "codeisland" + static let current = "uniisland" static let legacyNames = ["vibenotch", "vibe-island", "vibeisland"] static func isOurs(_ s: String) -> Bool { let lower = s.lowercased() @@ -112,25 +112,25 @@ struct CustomCLIConfig: Codable, Identifiable, Equatable { } struct ConfigInstaller { - private static let codeislandDir = NSHomeDirectory() + "/.codeisland" - private static let bridgePath = codeislandDir + "/codeisland-bridge" - private static let hookScriptPath = codeislandDir + "/codeisland-hook.sh" - private static let hookCommand = "~/.codeisland/codeisland-hook.sh" + private static let uniislandDir = NSHomeDirectory() + "/.uniisland" + private static let bridgePath = uniislandDir + "/uniisland-bridge" + private static let hookScriptPath = uniislandDir + "/uniisland-hook.sh" + private static let hookCommand = "~/.uniisland/uniisland-hook.sh" private static let customCLIConfigsKey = SessionSnapshot.customCLIConfigsKey /// Absolute path for external CLI hooks — avoids tilde expansion issues in IDE environments - private static let bridgeCommand = codeislandDir + "/codeisland-bridge" + private static let bridgeCommand = uniislandDir + "/uniisland-bridge" private static let traecliConfigPath = NSHomeDirectory() + "/.trae/traecli.yaml" private static let piAgentDir = NSHomeDirectory() + "/.pi/agent" private static let piExtensionDir = NSHomeDirectory() + "/.pi/agent/extensions" - private static let piExtensionPath = NSHomeDirectory() + "/.pi/agent/extensions/codeisland.ts" + private static let piExtensionPath = NSHomeDirectory() + "/.pi/agent/extensions/uniisland.ts" private static let ompAgentDir = NSHomeDirectory() + "/.omp/agent" private static let ompExtensionDir = NSHomeDirectory() + "/.omp/agent/extensions" - private static let ompExtensionPath = NSHomeDirectory() + "/.omp/agent/extensions/codeisland.ts" + private static let ompExtensionPath = NSHomeDirectory() + "/.omp/agent/extensions/uniisland.ts" // Legacy paths for migration cleanup (#32) - private static let legacyBridgePath = NSHomeDirectory() + "/.claude/hooks/codeisland-bridge" - private static let legacyHookScriptPath = NSHomeDirectory() + "/.claude/hooks/codeisland-hook.sh" + private static let legacyBridgePath = NSHomeDirectory() + "/.claude/hooks/uniisland-bridge" + private static let legacyHookScriptPath = NSHomeDirectory() + "/.claude/hooks/uniisland-hook.sh" // MARK: - Codex home resolution @@ -332,7 +332,7 @@ struct ConfigInstaller { // GitHub Copilot CLI CLIConfig( name: "Copilot", source: "copilot", - configPath: ".copilot/hooks/codeisland.json", configKey: "hooks", + configPath: ".copilot/hooks/uniisland.json", configKey: "hooks", format: .copilot, events: [ ("sessionStart", 5, false), @@ -350,11 +350,11 @@ struct ConfigInstaller { format: .kimi, events: defaultEvents(for: .kimi) ), - // Kiro CLI — agent-scoped JSON at ~/.kiro/agents/codeisland.json. - // User must launch with `kiro --agent codeisland` for hooks to fire (#127). + // Kiro CLI — agent-scoped JSON at ~/.kiro/agents/uniisland.json. + // User must launch with `kiro --agent uniisland` for hooks to fire (#127). CLIConfig( name: "Kiro", source: "kiro", - configPath: ".kiro/agents/codeisland.json", configKey: "hooks", + configPath: ".kiro/agents/uniisland.json", configKey: "hooks", format: .kiroAgent, events: defaultEvents(for: .kiroAgent) ), @@ -379,7 +379,7 @@ struct ConfigInstaller { CLIConfig( name: "pi", source: "pi", - configPath: ".pi/agent/extensions/codeisland.ts", + configPath: ".pi/agent/extensions/uniisland.ts", configKey: "", format: .none, events: [] @@ -389,7 +389,7 @@ struct ConfigInstaller { CLIConfig( name: "Oh My Pi", source: "omp", - configPath: ".omp/agent/extensions/codeisland.ts", + configPath: ".omp/agent/extensions/uniisland.ts", configKey: "", format: .none, events: [] @@ -585,13 +585,13 @@ struct ConfigInstaller { /// Hook script for Claude Code (dispatcher: bridge binary → nc fallback) private static let hookScript = """ #!/bin/bash - # CodeIsland hook v\(hookScriptVersion) — native bridge with shell fallback - BRIDGE="$HOME/.codeisland/codeisland-bridge" + # UniIsland hook v\(hookScriptVersion) — native bridge with shell fallback + BRIDGE="$HOME/.uniisland/uniisland-bridge" if [ -x "$BRIDGE" ]; then exec "$BRIDGE" "$@" fi # Fallback: original shell approach (no binary installed yet) - SOCK="/tmp/codeisland-$(id -u).sock" + SOCK="/tmp/uniisland-$(id -u).sock" [ -S "$SOCK" ] || exit 0 INPUT=$(cat) _ITERM_GUID="${ITERM_SESSION_ID##*:}" @@ -607,7 +607,7 @@ struct ConfigInstaller { // MARK: - OpenCode plugin paths private static let opencodePluginDir = NSHomeDirectory() + "/.config/opencode/plugins" - private static let opencodePluginPath = NSHomeDirectory() + "/.config/opencode/plugins/codeisland.js" + private static let opencodePluginPath = NSHomeDirectory() + "/.config/opencode/plugins/uniisland.js" private static let opencodeConfigPath = NSHomeDirectory() + "/.config/opencode/config.json" private static let opencodeConfigPathNew = NSHomeDirectory() + "/.config/opencode/opencode.json" // OpenCode recommends opencode.jsonc (with-comments). When the user already @@ -620,8 +620,8 @@ struct ConfigInstaller { static func install() -> Bool { let fm = FileManager.default - // Ensure ~/.codeisland directory - try? fm.createDirectory(atPath: codeislandDir, withIntermediateDirectories: true) + // Ensure ~/.uniisland directory + try? fm.createDirectory(atPath: uniislandDir, withIntermediateDirectories: true) // Clean up legacy paths at ~/.claude/hooks/ (#32) try? fm.removeItem(atPath: legacyBridgePath) @@ -1172,11 +1172,11 @@ struct ConfigInstaller { } else if cli.format == .kiroAgent, (originalText == nil || originalText?.isEmpty == true) { // Kiro agent JSON requires at minimum a "name" field. Seed a minimal agent // skeleton so the file is a valid Kiro agent the user can launch with - // `kiro --agent codeisland`. + // `kiro --agent uniisland`. seeded = """ { - "name": "codeisland", - "description": "Auto-generated by CodeIsland — relays Kiro hook events to the macOS Dynamic Island. Launch with `kiro --agent codeisland`." + "name": "uniisland", + "description": "Auto-generated by UniIsland — relays Kiro hook events to the macOS Dynamic Island. Launch with `kiro --agent uniisland`." } """ } @@ -1419,8 +1419,8 @@ struct ConfigInstaller { let base = bridgeCommand.contains(" ") ? "\"\(bridgeCommand)\"" : bridgeCommand let abs = "\(bridgeCommand) --source \(source)" let absQuoted = "\"\(bridgeCommand)\" --source \(source)" - let tilde = "~/.codeisland/codeisland-bridge --source \(source)" - let tildeQuoted = "\"~/.codeisland/codeisland-bridge\" --source \(source)" + let tilde = "~/.uniisland/uniisland-bridge --source \(source)" + let tildeQuoted = "\"~/.uniisland/uniisland-bridge\" --source \(source)" let actualRendered = "\(base) --source \(source)" return [actualRendered, abs, absQuoted, tilde, tildeQuoted] } @@ -1787,7 +1787,7 @@ struct ConfigInstaller { .map { line in let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("hooks =") { - return "# [CodeIsland] commented out legacy scalar hooks to avoid TOML conflict\n# \(line)" + return "# [UniIsland] commented out legacy scalar hooks to avoid TOML conflict\n# \(line)" } return line } @@ -1837,7 +1837,7 @@ struct ConfigInstaller { j += 1 } let blockText = blockLines.joined(separator: "\n") - if !blockText.contains("codeisland-bridge") { + if !blockText.contains("uniisland-bridge") { result.append(contentsOf: blockLines) } i = j @@ -1886,7 +1886,7 @@ struct ConfigInstaller { .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) currentEvent = val } - if currentEvent == event && trimmed.contains("codeisland-bridge") { + if currentEvent == event && trimmed.contains("uniisland-bridge") { return true } } @@ -1912,7 +1912,7 @@ struct ConfigInstaller { var restored: [String] = [] for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed == "# [CodeIsland] commented out legacy scalar hooks to avoid TOML conflict" { + if trimmed == "# [UniIsland] commented out legacy scalar hooks to avoid TOML conflict" { continue } if trimmed.range(of: #"^#\s*hooks\s*="#, options: .regularExpression) != nil { @@ -1950,15 +1950,15 @@ struct ConfigInstaller { // MARK: - Cline file-based hooks - private static let clineHookMarker = "codeisland-bridge --source cline" + private static let clineHookMarker = "uniisland-bridge --source cline" // Cline requires valid JSON on stdout from every hook invocation. // Run the bridge in the background (forwarding stdin) so it can report - // status to CodeIsland, then immediately return {"cancel":false} to Cline. + // status to UniIsland, then immediately return {"cancel":false} to Cline. private static let clineHookScript = """ #!/bin/bash INPUT=$(cat) - printf '%s' "$INPUT" | ~/.codeisland/codeisland-bridge --source cline "$@" >/dev/null 2>&1 & + printf '%s' "$INPUT" | ~/.uniisland/uniisland-bridge --source cline "$@" >/dev/null 2>&1 & printf '{"cancel":false}' """ @@ -2096,7 +2096,7 @@ struct ConfigInstaller { if let existing = fm.contents(atPath: hookScriptPath), let str = String(data: existing, encoding: .utf8) { // Update if script doesn't contain bridge dispatcher OR version is outdated - let hasCurrentVersion = str.contains("# CodeIsland hook v\(hookScriptVersion)") + let hasCurrentVersion = str.contains("# UniIsland hook v\(hookScriptVersion)") needsUpdate = !hasCurrentVersion } else { needsUpdate = true @@ -2114,8 +2114,8 @@ struct ConfigInstaller { guard let execPath = Bundle.main.executablePath else { return } let execDir = (execPath as NSString).deletingLastPathComponent let contentsDir = (execDir as NSString).deletingLastPathComponent - var srcPath = contentsDir + "/Helpers/codeisland-bridge" - if !fm.fileExists(atPath: srcPath) { srcPath = execDir + "/codeisland-bridge" } + var srcPath = contentsDir + "/Helpers/uniisland-bridge" + if !fm.fileExists(atPath: srcPath) { srcPath = execDir + "/uniisland-bridge" } guard fm.fileExists(atPath: srcPath) else { return } // Atomic replace: copy to temp file first, then rename (overwrites atomically) @@ -2147,23 +2147,23 @@ struct ConfigInstaller { /// The JS plugin source — embedded as resource or bundled alongside private static func opencodePluginSource() -> String? { // Try SPM resource bundle (where build actually places it) - if let url = Bundle.appModule.url(forResource: "codeisland-opencode", withExtension: "js", subdirectory: "Resources"), + if let url = Bundle.appModule.url(forResource: "uniisland-opencode", withExtension: "js", subdirectory: "Resources"), let src = try? String(contentsOf: url) { return src } // Fallback: try without subdirectory - if let url = Bundle.appModule.url(forResource: "codeisland-opencode", withExtension: "js"), + if let url = Bundle.appModule.url(forResource: "uniisland-opencode", withExtension: "js"), let src = try? String(contentsOf: url) { return src } return nil } // MARK: - pi Extension - /// Current pi extension version — bump when codeisland-pi.ts changes. + /// Current pi extension version — bump when uniisland-pi.ts changes. private static let piExtensionVersion = "v1" private static func piExtensionSource() -> String? { - if let url = Bundle.appModule.url(forResource: "codeisland-pi", withExtension: "ts", subdirectory: "Resources"), + if let url = Bundle.appModule.url(forResource: "uniisland-pi", withExtension: "ts", subdirectory: "Resources"), let src = try? String(contentsOf: url) { return src } - if let url = Bundle.appModule.url(forResource: "codeisland-pi", withExtension: "ts"), + if let url = Bundle.appModule.url(forResource: "uniisland-pi", withExtension: "ts"), let src = try? String(contentsOf: url) { return src } return nil } @@ -2189,7 +2189,7 @@ struct ConfigInstaller { guard fm.fileExists(atPath: piExtensionPath), let data = fm.contents(atPath: piExtensionPath), let content = String(data: data, encoding: .utf8), - content.contains("CodeIsland pi extension") + content.contains("UniIsland pi extension") else { return } try? fm.removeItem(atPath: piExtensionPath) } @@ -2202,14 +2202,14 @@ struct ConfigInstaller { let data = fm.contents(atPath: piExtensionPath), let content = String(data: data, encoding: .utf8) else { return false } - return content.contains("CodeIsland pi extension") + return content.contains("UniIsland pi extension") && content.contains("// version: \(piExtensionVersion)") } private static func ompExtensionSource() -> String? { - if let url = Bundle.appModule.url(forResource: "codeisland-omp", withExtension: "ts", subdirectory: "Resources"), + if let url = Bundle.appModule.url(forResource: "uniisland-omp", withExtension: "ts", subdirectory: "Resources"), let src = try? String(contentsOf: url) { return src } - if let url = Bundle.appModule.url(forResource: "codeisland-omp", withExtension: "ts"), + if let url = Bundle.appModule.url(forResource: "uniisland-omp", withExtension: "ts"), let src = try? String(contentsOf: url) { return src } return nil } @@ -2385,19 +2385,19 @@ struct ConfigInstaller { private static func backupOpencodeConfig(at path: String, original: String, fm: FileManager) { let dir = (path as NSString).deletingLastPathComponent let name = (path as NSString).lastPathComponent - // Skip if any previous codeisland backup exists for this file. + // Skip if any previous uniisland backup exists for this file. if let entries = try? fm.contentsOfDirectory(atPath: dir), - entries.contains(where: { $0.hasPrefix(name + ".codeisland.bak.") }) { + entries.contains(where: { $0.hasPrefix(name + ".uniisland.bak.") }) { return } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime] let stamp = formatter.string(from: Date()).replacingOccurrences(of: ":", with: "") - let backupPath = "\(path).codeisland.bak.\(stamp)" + let backupPath = "\(path).uniisland.bak.\(stamp)" fm.createFile(atPath: backupPath, contents: Data(original.utf8)) } - /// Current OpenCode plugin version — bump when codeisland-opencode.js changes + /// Current OpenCode plugin version — bump when uniisland-opencode.js changes private static let opencodePluginVersion = "v4" private static func isOpencodePluginInstalled(fm: FileManager) -> Bool { diff --git a/Sources/CodeIsland/CopilotView.swift b/Sources/UniIsland/CopilotView.swift similarity index 99% rename from Sources/CodeIsland/CopilotView.swift rename to Sources/UniIsland/CopilotView.swift index 99251141..bb32ba0f 100644 --- a/Sources/CodeIsland/CopilotView.swift +++ b/Sources/UniIsland/CopilotView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// CopilotBot — GitHub Copilot CLI mascot, adapted from copilot-avatar.svg. /// Two hollow ear loops (╭─╮╭─╮) on top, rose-framed face with gold dot eyes, diff --git a/Sources/CodeIsland/CursorView.swift b/Sources/UniIsland/CursorView.swift similarity index 99% rename from Sources/CodeIsland/CursorView.swift rename to Sources/UniIsland/CursorView.swift index 91a3e235..f91c9ad1 100644 --- a/Sources/CodeIsland/CursorView.swift +++ b/Sources/UniIsland/CursorView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// CursorBot — Cursor AI mascot, pixel-art hexagonal gem with diagonal highlight. /// Based on Cursor's actual logo: a faceted polyhedron with a bright diagonal slash. diff --git a/Sources/CodeIsland/DebugHarness.swift b/Sources/UniIsland/DebugHarness.swift similarity index 99% rename from Sources/CodeIsland/DebugHarness.swift rename to Sources/UniIsland/DebugHarness.swift index 327d1bbe..82e44fdf 100644 --- a/Sources/CodeIsland/DebugHarness.swift +++ b/Sources/UniIsland/DebugHarness.swift @@ -1,10 +1,10 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore // MARK: - Preview Scenario System // // Usage: launch with --preview to inject mock sessions for UI development. -// e.g. .build/debug/CodeIsland --preview approval +// e.g. .build/debug/UniIsland --preview approval // // Scenarios: // working — single session actively running tools diff --git a/Sources/CodeIsland/DexView.swift b/Sources/UniIsland/DexView.swift similarity index 99% rename from Sources/CodeIsland/DexView.swift rename to Sources/UniIsland/DexView.swift index 68d28d7a..83731265 100644 --- a/Sources/CodeIsland/DexView.swift +++ b/Sources/UniIsland/DexView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// Dex — Codex mascot, pixel-art cloud with terminal prompt face. /// Inspired by Codex's cloud icon with `>_` symbol. OpenAI black & white style. diff --git a/Sources/CodeIsland/DiagnosticsExporter.swift b/Sources/UniIsland/DiagnosticsExporter.swift similarity index 95% rename from Sources/CodeIsland/DiagnosticsExporter.swift rename to Sources/UniIsland/DiagnosticsExporter.swift index 47218be4..d3824a1f 100644 --- a/Sources/CodeIsland/DiagnosticsExporter.swift +++ b/Sources/UniIsland/DiagnosticsExporter.swift @@ -1,6 +1,6 @@ import AppKit import Foundation -import CodeIslandCore +import UniIslandCore /// One-click diagnostics export for bug reports. /// Collects app metadata, settings, session state, CLI configs, and recent logs into a zip. @@ -8,7 +8,7 @@ struct DiagnosticsExporter { static func export() { let panel = NSSavePanel() - panel.nameFieldStringValue = "CodeIsland-Diagnostics-\(timestamp()).zip" + panel.nameFieldStringValue = "UniIsland-Diagnostics-\(timestamp()).zip" panel.allowedContentTypes = [.zip] guard panel.runModal() == .OK, let url = panel.url else { return } @@ -33,8 +33,8 @@ struct DiagnosticsExporter { private static func buildArchive(saveTo destination: URL) throws -> URL { let fm = FileManager.default - let tmp = fm.temporaryDirectory.appendingPathComponent("CodeIsland-Diag-\(UUID().uuidString)", isDirectory: true) - let root = tmp.appendingPathComponent("CodeIsland-Diagnostics-\(timestamp())", isDirectory: true) + let tmp = fm.temporaryDirectory.appendingPathComponent("UniIsland-Diag-\(UUID().uuidString)", isDirectory: true) + let root = tmp.appendingPathComponent("UniIsland-Diagnostics-\(timestamp())", isDirectory: true) try fm.createDirectory(at: root, withIntermediateDirectories: true) // 1. Metadata @@ -60,7 +60,7 @@ struct DiagnosticsExporter { ("\(home)/.qoder/settings.json", "configs/qoder-settings.json"), ("\(home)/.factory/settings.json", "configs/factory-settings.json"), ("\(home)/.codebuddy/settings.json", "configs/codebuddy-settings.json"), - ("\(home)/.codeisland/sessions.json", "configs/persisted-sessions.json"), + ("\(home)/.uniisland/sessions.json", "configs/persisted-sessions.json"), ] for item in configs { copyIfExists(from: item.source, to: root.appendingPathComponent(item.dest)) @@ -75,7 +75,7 @@ struct DiagnosticsExporter { // 5. Unified system logs (last 2 hours) let logOutput = runCommand("/usr/bin/log", args: [ "show", "--style", "compact", "--info", "--debug", - "--last", "2h", "--predicate", "subsystem == \"com.codeisland\"" + "--last", "2h", "--predicate", "subsystem == \"com.uniisland\"" ]) try? logOutput.write(to: root.appendingPathComponent("logs/unified.log"), atomically: true, encoding: .utf8) @@ -209,7 +209,7 @@ struct DiagnosticsExporter { .appendingPathComponent("Library/Logs/DiagnosticReports") guard let files = try? fm.contentsOfDirectory(at: diagDir, includingPropertiesForKeys: [.contentModificationDateKey]) else { return } let recent = files - .filter { $0.lastPathComponent.lowercased().contains("codeisland") } + .filter { $0.lastPathComponent.lowercased().contains("uniisland") } .sorted { let d1 = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast let d2 = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast diff --git a/Sources/CodeIsland/DroidView.swift b/Sources/UniIsland/DroidView.swift similarity index 99% rename from Sources/CodeIsland/DroidView.swift rename to Sources/UniIsland/DroidView.swift index aaaedce5..bb6290e6 100644 --- a/Sources/CodeIsland/DroidView.swift +++ b/Sources/UniIsland/DroidView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// DroidBot — Factory/Droid mascot, pixel-art industrial robot. /// Rust orange #D56A26 on warm brown-black #161413. Mechanical/factory aesthetic. diff --git a/Sources/CodeIsland/ESP32BridgeManager.swift b/Sources/UniIsland/ESP32BridgeManager.swift similarity index 99% rename from Sources/CodeIsland/ESP32BridgeManager.swift rename to Sources/UniIsland/ESP32BridgeManager.swift index 08e973d0..9986bbb1 100644 --- a/Sources/CodeIsland/ESP32BridgeManager.swift +++ b/Sources/UniIsland/ESP32BridgeManager.swift @@ -3,7 +3,7 @@ import Foundation import Observation import os import Security -import CodeIslandCore +import UniIslandCore enum BuddyWritePriority: Int, Comparable { case auxiliary = 0 @@ -118,7 +118,7 @@ struct DiscoveredBuddy: Identifiable, Equatable { final class ESP32BridgeManager: NSObject { static let shared = ESP32BridgeManager() - private static let log = Logger(subsystem: "com.codeisland", category: "esp32-bridge") + private static let log = Logger(subsystem: "com.uniisland", category: "esp32-bridge") // Observable for SettingsView private(set) var status: ESP32BridgeStatus = .off diff --git a/Sources/CodeIsland/ESP32FocusCoordinator.swift b/Sources/UniIsland/ESP32FocusCoordinator.swift similarity index 95% rename from Sources/CodeIsland/ESP32FocusCoordinator.swift rename to Sources/UniIsland/ESP32FocusCoordinator.swift index 52a12801..4674fb37 100644 --- a/Sources/CodeIsland/ESP32FocusCoordinator.swift +++ b/Sources/UniIsland/ESP32FocusCoordinator.swift @@ -1,7 +1,7 @@ import AppKit import Foundation import os -import CodeIslandCore +import UniIslandCore /// Turns a button press from Buddy (1-byte `sourceId`) into a real /// "focus that agent's terminal/window" action. @@ -13,7 +13,7 @@ import CodeIslandCore /// tab / Kitty window / tmux pane / Cursor project window / etc. @MainActor enum ESP32FocusCoordinator { - private static let log = Logger(subsystem: "com.codeisland", category: "esp32-focus") + private static let log = Logger(subsystem: "com.uniisland", category: "esp32-focus") /// Ordered status priority — richer statuses win the tiebreak so that a /// button press preferentially lands on the session actually needing diff --git a/Sources/CodeIsland/ESP32StatePublisher.swift b/Sources/UniIsland/ESP32StatePublisher.swift similarity index 99% rename from Sources/CodeIsland/ESP32StatePublisher.swift rename to Sources/UniIsland/ESP32StatePublisher.swift index 21d916f2..5d3640e3 100644 --- a/Sources/CodeIsland/ESP32StatePublisher.swift +++ b/Sources/UniIsland/ESP32StatePublisher.swift @@ -1,6 +1,6 @@ import Foundation import os -import CodeIslandCore +import UniIslandCore /// Drives the Buddy bridge: pushes the *currently displayed* mascot/status /// both on every AppState mutation (via `notifyDirty()`) and on a fixed @@ -14,7 +14,7 @@ import CodeIslandCore final class ESP32StatePublisher { static let shared = ESP32StatePublisher() - private static let log = Logger(subsystem: "com.codeisland", category: "esp32-publisher") + private static let log = Logger(subsystem: "com.uniisland", category: "esp32-publisher") private weak var appState: AppState? private let bridge: ESP32BridgeManager diff --git a/Sources/CodeIsland/GeminiView.swift b/Sources/UniIsland/GeminiView.swift similarity index 99% rename from Sources/CodeIsland/GeminiView.swift rename to Sources/UniIsland/GeminiView.swift index d173851f..264f4da4 100644 --- a/Sources/CodeIsland/GeminiView.swift +++ b/Sources/UniIsland/GeminiView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// Gemini — Google Gemini CLI mascot, four-pointed sparkle star. /// Blue→Purple→Rose gradient (#4796E4 → #847ACE → #C3677F). diff --git a/Sources/CodeIsland/HermesView.swift b/Sources/UniIsland/HermesView.swift similarity index 99% rename from Sources/CodeIsland/HermesView.swift rename to Sources/UniIsland/HermesView.swift index 3884af16..2947ebd4 100644 --- a/Sources/CodeIsland/HermesView.swift +++ b/Sources/UniIsland/HermesView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// HermesBot — Hermes mascot, dark hooded figure with glowing eyes. /// Noir purple #2D1B4E with white highlights, mysterious aesthetic. diff --git a/Sources/CodeIsland/HookServer.swift b/Sources/UniIsland/HookServer.swift similarity index 98% rename from Sources/CodeIsland/HookServer.swift rename to Sources/UniIsland/HookServer.swift index ef60b83a..be668bfc 100644 --- a/Sources/CodeIsland/HookServer.swift +++ b/Sources/UniIsland/HookServer.swift @@ -1,9 +1,9 @@ import Foundation import Network import os.log -import CodeIslandCore +import UniIslandCore -private let log = Logger(subsystem: "com.codeisland", category: "HookServer") +private let log = Logger(subsystem: "com.uniisland", category: "HookServer") @MainActor class HookServer { @@ -184,7 +184,7 @@ class HookServer { var request = URLRequest(url: endpoint, timeoutInterval: 5) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("CodeIsland-Webhook/1.0", forHTTPHeaderField: "User-Agent") + request.setValue("UniIsland-Webhook/1.0", forHTTPHeaderField: "User-Agent") request.httpBody = body URLSession.shared.dataTask(with: request) { _, _, _ in @@ -491,7 +491,7 @@ class HookServer { /// /// Previously this used `connection.receive(min:1, max:1)` which triggered on EOF. /// But the bridge always does `shutdown(SHUT_WR)` after sending the request (see - /// CodeIslandBridge/main.swift), which produces an immediate EOF on the read side. + /// UniIslandBridge/main.swift), which produces an immediate EOF on the read side. /// That caused every PermissionRequest to be auto-drained as `deny` before the UI /// card was even visible. We now rely on `stateUpdateHandler` transitioning to /// `cancelled`/`failed` — which only happens on real socket teardown, not half-close. diff --git a/Sources/CodeIsland/IslandSurface.swift b/Sources/UniIsland/IslandSurface.swift similarity index 100% rename from Sources/CodeIsland/IslandSurface.swift rename to Sources/UniIsland/IslandSurface.swift diff --git a/Sources/CodeIsland/JSONMinimalEditor.swift b/Sources/UniIsland/JSONMinimalEditor.swift similarity index 100% rename from Sources/CodeIsland/JSONMinimalEditor.swift rename to Sources/UniIsland/JSONMinimalEditor.swift diff --git a/Sources/CodeIsland/KimiView.swift b/Sources/UniIsland/KimiView.swift similarity index 99% rename from Sources/CodeIsland/KimiView.swift rename to Sources/UniIsland/KimiView.swift index 0d30838e..4e1dcd5c 100644 --- a/Sources/CodeIsland/KimiView.swift +++ b/Sources/UniIsland/KimiView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// KimiBot — Kimi Code CLI mascot. /// Soft rounded cube with a small antenna, in Kimi blue. diff --git a/Sources/CodeIsland/L10n.swift b/Sources/UniIsland/L10n.swift similarity index 95% rename from Sources/CodeIsland/L10n.swift rename to Sources/UniIsland/L10n.swift index 8ea6fa8b..d3eebee1 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/UniIsland/L10n.swift @@ -48,6 +48,7 @@ final class L10n: ObservableObject { "remote": "Remote", "hooks": "Hooks", "about": "About", + "widgets": "Widgets", // Language "language": "Language", @@ -75,7 +76,7 @@ final class L10n: ObservableObject { "auto_collapse_after_session_jump": "Auto-collapse after session jump", "auto_collapse_after_session_jump_desc": "Collapse panel after clicking a session and successfully switching to its terminal/client", "auto_expand_on_completion": "Auto-Expand Panel on Agent Completion", - "auto_expand_on_completion_desc": "Briefly expand the panel when an agent or subagent finishes; turn off to keep CodeIsland in its compact state", + "auto_expand_on_completion_desc": "Briefly expand the panel when an agent or subagent finishes; turn off to keep UniIsland in its compact state", "haptic_on_hover": "Haptic Feedback on Hover", "haptic_on_hover_desc": "Trigger trackpad haptic feedback when hovering over the notch", "haptic_light": "Light", @@ -85,7 +86,7 @@ final class L10n: ObservableObject { "shortcut_recording": "Recording…", "shortcut_none": "Not Set", "shortcut_togglePanel": "Toggle Panel", - "shortcut_togglePanel_desc": "Open or close the CodeIsland panel", + "shortcut_togglePanel_desc": "Open or close the UniIsland panel", "shortcut_approve": "Approve", "shortcut_approve_desc": "Approve current permission request", "shortcut_approveAlways": "Approve Always", @@ -197,7 +198,7 @@ final class L10n: ObservableObject { "tool_or_api_error": "Tool failure or API error", "system_section": "System", "boot_sound": "Boot Sound", - "boot_sound_desc": "Play a jingle when CodeIsland starts", + "boot_sound_desc": "Play a jingle when UniIsland starts", "interaction": "Interaction", "approval_needed": "Approval Needed", "waiting_approval_desc": "Waiting for permission approval or answer", @@ -231,7 +232,7 @@ final class L10n: ObservableObject { "remote_auth_socket_placeholder": "~/.1password/agent.sock", "remote_auto_connect": "Auto-connect on Launch", "remote_add_button": "Add Host", - "remote_hint": "The host field can be a normal hostname or an alias from your ~/.ssh/config. CodeIsland installs a small remote hook script and forwards events over SSH.", + "remote_hint": "The host field can be a normal hostname or an alias from your ~/.ssh/config. UniIsland installs a small remote hook script and forwards events over SSH.", "remote_connect": "Connect", "remote_connecting": "Connecting…", "remote_connected": "Connected", @@ -286,7 +287,7 @@ final class L10n: ObservableObject { "about_desc2": "Supports 11 CLI/IDE tools via Unix socket IPC", // Window - "settings_title": "CodeIsland Settings", + "settings_title": "UniIsland Settings", // Menu "settings_ellipsis": "Settings...", @@ -299,11 +300,11 @@ final class L10n: ObservableObject { // Update "update_available_title": "Update Available", - "update_available_body": "CodeIsland %@ is available (current: %@). Would you like to download it?", + "update_available_body": "UniIsland %@ is available (current: %@). Would you like to download it?", "download_update": "Download", "later": "Later", "no_update_title": "Up to Date", - "no_update_body": "CodeIsland %@ is the latest version.", + "no_update_body": "UniIsland %@ is the latest version.", "ok": "OK", "update_now": "Update Now", "update_downloading": "Downloading update...", @@ -313,8 +314,8 @@ final class L10n: ObservableObject { "update_installing": "Installing update...", "update_retry": "Retry", "update_homebrew_title": "Update Available", - "update_homebrew_body": "CodeIsland %@ is available. Since you installed via Homebrew, please run:", - "update_homebrew_command": "brew upgrade codeisland", + "update_homebrew_body": "UniIsland %@ is available. Since you installed via Homebrew, please run:", + "update_homebrew_command": "brew upgrade uniisland", "update_copy_command": "Copy Command", // NotchPanel @@ -358,6 +359,7 @@ final class L10n: ObservableObject { "remote": "远程", "hooks": "Hooks", "about": "关于", + "widgets": "灵动组件", // Language "language": "语言", @@ -385,7 +387,7 @@ final class L10n: ObservableObject { "auto_collapse_after_session_jump": "点击跳转会话后自动收起面板", "auto_collapse_after_session_jump_desc": "点击会话并成功切换到对应终端/客户端后自动收起面板", "auto_expand_on_completion": "Agent 完成时自动展开面板", - "auto_expand_on_completion_desc": "Agent 或子 Agent 完成时短暂展开面板;关闭后 CodeIsland 始终保持紧凑状态", + "auto_expand_on_completion_desc": "Agent 或子 Agent 完成时短暂展开面板;关闭后 UniIsland 始终保持紧凑状态", "haptic_on_hover": "悬停触控板震动", "haptic_on_hover_desc": "鼠标悬停在刘海上时触发触控板震动反馈", "haptic_light": "轻", @@ -395,7 +397,7 @@ final class L10n: ObservableObject { "shortcut_recording": "请按下快捷键…", "shortcut_none": "未设置", "shortcut_togglePanel": "切换面板", - "shortcut_togglePanel_desc": "展开或收起 CodeIsland 面板", + "shortcut_togglePanel_desc": "展开或收起 UniIsland 面板", "shortcut_approve": "批准", "shortcut_approve_desc": "批准当前权限请求", "shortcut_approveAlways": "始终批准", @@ -507,7 +509,7 @@ final class L10n: ObservableObject { "tool_or_api_error": "工具失败或 API 错误", "system_section": "系统", "boot_sound": "启动音效", - "boot_sound_desc": "CodeIsland 启动时播放提示音", + "boot_sound_desc": "UniIsland 启动时播放提示音", "interaction": "交互", "approval_needed": "需要审批", "waiting_approval_desc": "等待权限审批或回答问题", @@ -541,7 +543,7 @@ final class L10n: ObservableObject { "remote_auth_socket_placeholder": "~/.1password/agent.sock", "remote_auto_connect": "启动时自动连接", "remote_add_button": "添加主机", - "remote_hint": "主机字段既可以填普通 hostname,也可以直接填 ~/.ssh/config 里的别名。CodeIsland 会在远端安装一个很小的 hook 脚本,并通过 SSH 转发事件回来。", + "remote_hint": "主机字段既可以填普通 hostname,也可以直接填 ~/.ssh/config 里的别名。UniIsland 会在远端安装一个很小的 hook 脚本,并通过 SSH 转发事件回来。", "remote_connect": "连接", "remote_connecting": "连接中…", "remote_connected": "已连接", @@ -596,7 +598,7 @@ final class L10n: ObservableObject { "about_desc2": "通过 Unix socket IPC 支持 11 种 CLI/IDE 工具", // Window - "settings_title": "CodeIsland 设置", + "settings_title": "UniIsland 设置", // Menu "settings_ellipsis": "设置...", @@ -609,11 +611,11 @@ final class L10n: ObservableObject { // Update "update_available_title": "发现新版本", - "update_available_body": "CodeIsland %@ 已发布(当前版本:%@),是否前往下载?", + "update_available_body": "UniIsland %@ 已发布(当前版本:%@),是否前往下载?", "download_update": "前往下载", "later": "稍后", "no_update_title": "已是最新版本", - "no_update_body": "CodeIsland %@ 已是最新版本。", + "no_update_body": "UniIsland %@ 已是最新版本。", "ok": "好", "update_now": "立即更新", "update_downloading": "正在下载更新...", @@ -623,8 +625,8 @@ final class L10n: ObservableObject { "update_installing": "正在安装更新...", "update_retry": "重试", "update_homebrew_title": "发现新版本", - "update_homebrew_body": "CodeIsland %@ 已发布。由于您通过 Homebrew 安装,请运行:", - "update_homebrew_command": "brew upgrade codeisland", + "update_homebrew_body": "UniIsland %@ 已发布。由于您通过 Homebrew 安装,请运行:", + "update_homebrew_command": "brew upgrade uniisland", "update_copy_command": "复制命令", // NotchPanel @@ -705,7 +707,7 @@ final class L10n: ObservableObject { "shortcut_recording": "記録中…", "shortcut_none": "未設定", "shortcut_togglePanel": "パネルを切り替え", - "shortcut_togglePanel_desc": "CodeIsland パネルを開閉します", + "shortcut_togglePanel_desc": "UniIsland パネルを開閉します", "shortcut_approve": "承認", "shortcut_approve_desc": "現在の権限リクエストを承認します", "shortcut_approveAlways": "常に承認", @@ -817,7 +819,7 @@ final class L10n: ObservableObject { "tool_or_api_error": "ツール失敗または API エラー", "system_section": "システム", "boot_sound": "起動音", - "boot_sound_desc": "CodeIsland の起動時にジングルを再生します", + "boot_sound_desc": "UniIsland の起動時にジングルを再生します", "interaction": "操作", "approval_needed": "承認が必要", "waiting_approval_desc": "権限承認または回答を待っています", @@ -851,7 +853,7 @@ final class L10n: ObservableObject { "remote_auth_socket_placeholder": "~/.1password/agent.sock", "remote_auto_connect": "起動時に自動接続", "remote_add_button": "ホストを追加", - "remote_hint": "ホスト欄には通常のホスト名または ~/.ssh/config のエイリアスを指定できます。CodeIsland は小さなリモート hook スクリプトをインストールし、SSH 経由でイベントを転送します。", + "remote_hint": "ホスト欄には通常のホスト名または ~/.ssh/config のエイリアスを指定できます。UniIsland は小さなリモート hook スクリプトをインストールし、SSH 経由でイベントを転送します。", "remote_connect": "接続", "remote_connecting": "接続中…", "remote_connected": "接続済み", @@ -906,7 +908,7 @@ final class L10n: ObservableObject { "about_desc2": "Unix socket IPC を通じて 11 種類の CLI/IDE ツールをサポート", // Window - "settings_title": "CodeIsland 設定", + "settings_title": "UniIsland 設定", // Menu "settings_ellipsis": "設定...", @@ -919,11 +921,11 @@ final class L10n: ObservableObject { // Update "update_available_title": "アップデートがあります", - "update_available_body": "CodeIsland %@ が利用可能です (現在: %@)。ダウンロードしますか?", + "update_available_body": "UniIsland %@ が利用可能です (現在: %@)。ダウンロードしますか?", "download_update": "ダウンロード", "later": "後で", "no_update_title": "最新です", - "no_update_body": "CodeIsland %@ は最新バージョンです。", + "no_update_body": "UniIsland %@ は最新バージョンです。", "ok": "OK", "update_now": "今すぐアップデート", "update_downloading": "アップデートをダウンロード中...", @@ -933,8 +935,8 @@ final class L10n: ObservableObject { "update_installing": "アップデートをインストール中...", "update_retry": "再試行", "update_homebrew_title": "アップデートがあります", - "update_homebrew_body": "CodeIsland %@ が利用可能です。Homebrew でインストールしたため、次を実行してください:", - "update_homebrew_command": "brew upgrade codeisland", + "update_homebrew_body": "UniIsland %@ が利用可能です。Homebrew でインストールしたため、次を実行してください:", + "update_homebrew_command": "brew upgrade uniisland", "update_copy_command": "コマンドをコピー", // NotchPanel @@ -1005,7 +1007,7 @@ final class L10n: ObservableObject { "auto_collapse_after_session_jump": "세션 이동 후 자동 접기", "auto_collapse_after_session_jump_desc": "세션을 클릭해 해당 터미널이나 클라이언트로 성공적으로 전환한 뒤 패널을 자동으로 접습니다", "auto_expand_on_completion": "에이전트 완료 시 패널 자동 확장", - "auto_expand_on_completion_desc": "에이전트 또는 하위 에이전트가 완료되면 패널을 잠시 확장합니다. 끄면 CodeIsland가 항상 컴팩트 상태로 유지됩니다", + "auto_expand_on_completion_desc": "에이전트 또는 하위 에이전트가 완료되면 패널을 잠시 확장합니다. 끄면 UniIsland가 항상 컴팩트 상태로 유지됩니다", "haptic_on_hover": "호버 시 햅틱 피드백", "haptic_on_hover_desc": "노치 위에 마우스를 올리면 트랙패드 햅틱 피드백을 발생시킵니다", "haptic_light": "약함", @@ -1015,7 +1017,7 @@ final class L10n: ObservableObject { "shortcut_recording": "기록 중…", "shortcut_none": "설정되지 않음", "shortcut_togglePanel": "패널 전환", - "shortcut_togglePanel_desc": "CodeIsland 패널을 열거나 닫습니다", + "shortcut_togglePanel_desc": "UniIsland 패널을 열거나 닫습니다", "shortcut_approve": "승인", "shortcut_approve_desc": "현재 권한 요청을 승인합니다", "shortcut_approveAlways": "항상 승인", @@ -1127,7 +1129,7 @@ final class L10n: ObservableObject { "tool_or_api_error": "도구 실패 또는 API 오류", "system_section": "시스템", "boot_sound": "시작 사운드", - "boot_sound_desc": "CodeIsland가 시작될 때 알림음을 재생합니다", + "boot_sound_desc": "UniIsland가 시작될 때 알림음을 재생합니다", "interaction": "상호작용", "approval_needed": "승인 필요", "waiting_approval_desc": "권한 승인 또는 답변을 기다리는 중입니다", @@ -1161,7 +1163,7 @@ final class L10n: ObservableObject { "remote_auth_socket_placeholder": "~/.1password/agent.sock", "remote_auto_connect": "시작 시 자동 연결", "remote_add_button": "호스트 추가", - "remote_hint": "호스트 필드에는 일반 호스트명이나 ~/.ssh/config의 별칭을 사용할 수 있습니다. CodeIsland는 작은 원격 hook 스크립트를 설치하고 SSH를 통해 이벤트를 전달합니다.", + "remote_hint": "호스트 필드에는 일반 호스트명이나 ~/.ssh/config의 별칭을 사용할 수 있습니다. UniIsland는 작은 원격 hook 스크립트를 설치하고 SSH를 통해 이벤트를 전달합니다.", "remote_connect": "연결", "remote_connecting": "연결 중…", "remote_connected": "연결됨", @@ -1216,7 +1218,7 @@ final class L10n: ObservableObject { "about_desc2": "Unix socket IPC를 통해 11개의 CLI/IDE 도구를 지원합니다", // Window - "settings_title": "CodeIsland 설정", + "settings_title": "UniIsland 설정", // Menu "settings_ellipsis": "설정...", @@ -1229,11 +1231,11 @@ final class L10n: ObservableObject { // Update "update_available_title": "업데이트 가능", - "update_available_body": "CodeIsland %@ 버전을 사용할 수 있습니다(현재: %@). 다운로드하시겠습니까?", + "update_available_body": "UniIsland %@ 버전을 사용할 수 있습니다(현재: %@). 다운로드하시겠습니까?", "download_update": "다운로드", "later": "나중에", "no_update_title": "최신 상태", - "no_update_body": "CodeIsland %@이 최신 버전입니다.", + "no_update_body": "UniIsland %@이 최신 버전입니다.", "ok": "확인", "update_now": "지금 업데이트", "update_downloading": "업데이트 다운로드 중...", @@ -1243,8 +1245,8 @@ final class L10n: ObservableObject { "update_installing": "업데이트 설치 중...", "update_retry": "다시 시도", "update_homebrew_title": "업데이트 가능", - "update_homebrew_body": "CodeIsland %@ 버전을 사용할 수 있습니다. Homebrew로 설치했으므로 다음 명령을 실행하세요:", - "update_homebrew_command": "brew upgrade codeisland", + "update_homebrew_body": "UniIsland %@ 버전을 사용할 수 있습니다. Homebrew로 설치했으므로 다음 명령을 실행하세요:", + "update_homebrew_command": "brew upgrade uniisland", "update_copy_command": "명령 복사", // NotchPanel @@ -1315,7 +1317,7 @@ final class L10n: ObservableObject { "auto_collapse_after_session_jump": "Oturuma Geçince Otomatik Daralt", "auto_collapse_after_session_jump_desc": "Bir oturuma tıklayıp terminal/istemciye başarıyla geçince paneli otomatik daralt", "auto_expand_on_completion": "Ajan Tamamlandığında Paneli Otomatik Genişlet", - "auto_expand_on_completion_desc": "Bir ajan veya alt-ajan işini bitirdiğinde paneli kısa süreliğine genişletir; kapatırsanız CodeIsland kompakt durumda kalır", + "auto_expand_on_completion_desc": "Bir ajan veya alt-ajan işini bitirdiğinde paneli kısa süreliğine genişletir; kapatırsanız UniIsland kompakt durumda kalır", "haptic_on_hover": "Üzerine Gelince Dokunsal Geri Bildirim", "haptic_on_hover_desc": "Çentik üzerine geldiğinizde dokunmatik yüzey dokunsal geri bildirimi tetikle", "haptic_light": "Hafif", @@ -1325,7 +1327,7 @@ final class L10n: ObservableObject { "shortcut_recording": "Kaydediliyor…", "shortcut_none": "Ayarlanmamış", "shortcut_togglePanel": "Paneli Aç/Kapa", - "shortcut_togglePanel_desc": "CodeIsland panelini aç veya kapat", + "shortcut_togglePanel_desc": "UniIsland panelini aç veya kapat", "shortcut_approve": "İzin Ver", "shortcut_approve_desc": "Mevcut izin isteğini onayla", "shortcut_approveAlways": "Her Zaman İzin Ver", @@ -1437,7 +1439,7 @@ final class L10n: ObservableObject { "tool_or_api_error": "Araç veya API hatası", "system_section": "Sistem", "boot_sound": "Başlangıç Sesi", - "boot_sound_desc": "CodeIsland başladığında bir jingle çal", + "boot_sound_desc": "UniIsland başladığında bir jingle çal", "interaction": "Etkileşim", "approval_needed": "Onay Gerekli", "waiting_approval_desc": "İzin onayı veya cevap bekleniyor", @@ -1471,7 +1473,7 @@ final class L10n: ObservableObject { "remote_auth_socket_placeholder": "~/.1password/agent.sock", "remote_auto_connect": "Başlangıçta Otomatik Bağlan", "remote_add_button": "Host Ekle", - "remote_hint": "Host alanı normal bir hostname veya ~/.ssh/config dosyanızdaki takma ad olabilir. CodeIsland küçük bir uzak hook betiği yükler ve olayları SSH üzerinden iletir.", + "remote_hint": "Host alanı normal bir hostname veya ~/.ssh/config dosyanızdaki takma ad olabilir. UniIsland küçük bir uzak hook betiği yükler ve olayları SSH üzerinden iletir.", "remote_connect": "Bağlan", "remote_connecting": "Bağlanıyor…", "remote_connected": "Bağlı", @@ -1526,7 +1528,7 @@ final class L10n: ObservableObject { "about_desc2": "Unix socket IPC üzerinden 11 CLI/IDE aracını destekler", // Window - "settings_title": "CodeIsland Ayarları", + "settings_title": "UniIsland Ayarları", // Menu "settings_ellipsis": "Ayarlar...", @@ -1539,11 +1541,11 @@ final class L10n: ObservableObject { // Update "update_available_title": "Güncelleme Mevcut", - "update_available_body": "CodeIsland %@ mevcut (şimdiki: %@). İndirmek ister misiniz?", + "update_available_body": "UniIsland %@ mevcut (şimdiki: %@). İndirmek ister misiniz?", "download_update": "İndir", "later": "Sonra", "no_update_title": "Güncel", - "no_update_body": "CodeIsland %@ en son sürüm.", + "no_update_body": "UniIsland %@ en son sürüm.", "ok": "Tamam", "update_now": "Şimdi Güncelle", "update_downloading": "Güncelleme indiriliyor...", @@ -1553,8 +1555,8 @@ final class L10n: ObservableObject { "update_installing": "Güncelleme yükleniyor...", "update_retry": "Tekrar Dene", "update_homebrew_title": "Güncelleme Mevcut", - "update_homebrew_body": "CodeIsland %@ mevcut. Homebrew ile yüklediğiniz için, lütfen şunu çalıştırın:", - "update_homebrew_command": "brew upgrade codeisland", + "update_homebrew_body": "UniIsland %@ mevcut. Homebrew ile yüklediğiniz için, lütfen şunu çalıştırın:", + "update_homebrew_command": "brew upgrade uniisland", "update_copy_command": "Komutu Kopyala", // NotchPanel diff --git a/Sources/CodeIsland/MascotView.swift b/Sources/UniIsland/MascotView.swift similarity index 82% rename from Sources/CodeIsland/MascotView.swift rename to Sources/UniIsland/MascotView.swift index 1d3a7ca9..6b2b3fa1 100644 --- a/Sources/CodeIsland/MascotView.swift +++ b/Sources/UniIsland/MascotView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore // MARK: - Mascot Animation Speed Environment @@ -19,22 +19,25 @@ struct MascotView: View { let source: String let status: AgentStatus var size: CGFloat = 27 + var isDraggingOver: Bool = false @AppStorage(SettingsKey.mascotSpeed) private var speedPct = SettingsDefaults.mascotSpeed var body: some View { Group { switch source { + case "claude": + ClawdView(status: status, size: size) case "codex": DexView(status: status, size: size) case "gemini": GeminiView(status: status, size: size) - case "cursor": + case "cursor", "cursor-cli": CursorView(status: status, size: size) case "trae", "traecn", "traecli": TraeView(status: status, size: size) case "copilot": CopilotView(status: status, size: size) - case "qoder": + case "qoder", "qoder-cli": QoderView(status: status, size: size) case "droid": DroidView(status: status, size: size) @@ -58,6 +61,10 @@ struct MascotView: View { KimiView(status: status, size: size) case "cline": ClineView(status: status, size: size) + case "wechat": + WeChatView(status: status, size: size) + case "cat", "kitten", "drops", "pomodoro": + CatView(status: status, size: size, isDraggingOver: isDraggingOver, isCoding: source == "pomodoro") default: ClawdView(status: status, size: size) } diff --git a/Sources/CodeIsland/Models.swift b/Sources/UniIsland/Models.swift similarity index 98% rename from Sources/CodeIsland/Models.swift rename to Sources/UniIsland/Models.swift index 63c9bebd..f7312865 100644 --- a/Sources/CodeIsland/Models.swift +++ b/Sources/UniIsland/Models.swift @@ -1,5 +1,5 @@ import Foundation -import CodeIslandCore +import UniIslandCore struct PermissionRequest { let event: HookEvent diff --git a/Sources/CodeIsland/NotchAnimation.swift b/Sources/UniIsland/NotchAnimation.swift similarity index 93% rename from Sources/CodeIsland/NotchAnimation.swift rename to Sources/UniIsland/NotchAnimation.swift index 4fdfbb85..4f5c063b 100644 --- a/Sources/CodeIsland/NotchAnimation.swift +++ b/Sources/UniIsland/NotchAnimation.swift @@ -9,6 +9,8 @@ enum NotchAnimation { static let pop = Animation.spring(response: 0.3, dampingFraction: 0.65) /// 微交互:hover 状态变化、按钮高亮等 static let micro = Animation.easeOut(duration: 0.12) + /// Hover preflight: the island acknowledges cursor entry before the delayed full expansion. + static let hoverPrehover = Animation.easeOut(duration: NotchHoverInteraction.prehoverAnimationDuration) } // MARK: - Blur + Fade transition diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/UniIsland/NotchPanelView.swift similarity index 66% rename from Sources/CodeIsland/NotchPanelView.swift rename to Sources/UniIsland/NotchPanelView.swift index 19a4fa09..3ec58b41 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/UniIsland/NotchPanelView.swift @@ -1,11 +1,102 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore +import UniformTypeIdentifiers enum NotchWidthMetrics { static func effectiveNotchWidth(notchW: CGFloat, collapsedWidthScale: Int) -> CGFloat { - let clampedScale = max(50, min(collapsedWidthScale, 150)) + let clampedScale = max(NotchWidthScale.min, min(collapsedWidthScale, NotchWidthScale.max)) return notchW * CGFloat(clampedScale) / 100.0 } + + static func expandedPanelWidth(notchW: CGFloat, collapsedWidthScale: Int, screenWidth: CGFloat) -> CGFloat { + let nw = effectiveNotchWidth(notchW: notchW, collapsedWidthScale: collapsedWidthScale) + let maxWidth = min(620, screenWidth - 40) + return min(max(nw + 200, 580), maxWidth) + } + + static func idlePanelWidth( + notchW: CGFloat, + compactWingWidth: CGFloat, + collapsedWidthScale: Int, + phase: NotchHoverPhase + ) -> CGFloat { + let nw = effectiveNotchWidth(notchW: notchW, collapsedWidthScale: collapsedWidthScale) + let base = nw + compactWingWidth * 2 + 43 + switch phase { + case .collapsed: + return base + case .prehover: + return base + NotchHoverInteraction.prehoverWidthDelta + case .expanded: + return base + 120 + } + } + + static func collapsedRightWingReservedWidth(showToolStatus: Bool, isAlerting: Bool) -> CGFloat { + let countWidth: CGFloat = showToolStatus ? 32 : 34 + return countWidth + (isAlerting ? 12 : 0) + } + + static func collapsedCenterGap( + effectiveNotchWidth: CGFloat, + physicalNotchWidth: CGFloat, + hasNotch: Bool + ) -> CGFloat { + guard hasNotch else { return effectiveNotchWidth } + return max(effectiveNotchWidth, physicalNotchWidth - 2) + } + + static func activeCollapsedPanelWidth( + scaledCenterGap: CGFloat, + compactWingWidth: CGFloat, + rightWingWidth: CGFloat, + prehoverExtra: CGFloat, + minimumVisibleWidth: CGFloat + ) -> CGFloat { + let scaledWidth = scaledCenterGap + + compactWingWidth + + rightWingWidth + + prehoverExtra + + NotchHoverInteraction.activeCollapsedWidthBonus + return max(scaledWidth, minimumVisibleWidth) + } +} + +enum NotchHoverPhase: Equatable { + case collapsed + case prehover + case expanded +} + +enum NotchHoverEvent { + case mouseEntered + case mouseExited + case expandDelayElapsed + case collapseDelayElapsed +} + +enum NotchHoverInteraction { + static let prehoverAnimationDuration: TimeInterval = 0.21 + static let expandDelay: TimeInterval = 0.5 + static let collapseDelay: TimeInterval = 0.5 + static let prehoverWidthDelta: CGFloat = 7 + static let prehoverScale: CGFloat = 1.004 + static let activeCollapsedWidthBonus: CGFloat = 60 + + static func nextPhase(from phase: NotchHoverPhase, event: NotchHoverEvent) -> NotchHoverPhase { + switch (phase, event) { + case (.collapsed, .mouseEntered): + return .prehover + case (.prehover, .mouseExited): + return .collapsed + case (.prehover, .expandDelayElapsed): + return .expanded + case (.expanded, .collapseDelayElapsed): + return .collapsed + default: + return phase + } + } } struct NotchPanelView: View { @@ -27,25 +118,33 @@ struct NotchPanelView: View { /// Delayed hover: prevents accidental expansion when mouse passes through @State private var hoverTimer: Timer? @State private var isHovered = false - @State private var idleHovered = false + @State private var hoverPhase: NotchHoverPhase = .collapsed /// Curtain animation for tool status toggle @State private var curtainOffset: CGFloat = 0 @State private var curtainOpacity: Double = 1 @State private var displayedToolStatus: Bool = SettingsDefaults.showToolStatus private var isActive: Bool { !appState.sessions.isEmpty } - /// First launch / no-session state should still render a visible marker so the app - /// doesn't disappear completely behind the physical notch. + /// Always show idle indicator when no sessions exist (regardless of hideWhenNoSession setting) private var showIdleIndicator: Bool { - !isActive && !hideWhenNoSession + !isActive } - /// Whether the bar content should be visible (respects hideWhenNoSession) + /// Whether the bar content should be visible private var showBar: Bool { - isActive && !(hideWhenNoSession && appState.activeSessionCount == 0) + isActive } private var shouldShowExpanded: Bool { showBar && appState.surface.isExpanded } + private var shouldShowPrehover: Bool { + showBar && !shouldShowExpanded && hoverPhase == .prehover + } + private var shouldShowIdleExpanded: Bool { + showIdleIndicator && hoverPhase == .expanded + } + private var shouldShowIdlePrehover: Bool { + showIdleIndicator && hoverPhase == .prehover + } /// Mascot size — fits within the menu bar height private var mascotSize: CGFloat { min(27, notchHeight - 6) } @@ -53,6 +152,22 @@ struct NotchPanelView: View { /// Minimum wing width needed to display compact bar content private var compactWingWidth: CGFloat { mascotSize + 14 } + private var isAlerting: Bool { + appState.status == .waitingApproval || appState.status == .waitingQuestion + } + + private var collapsedRightWingWidth: CGFloat { + let alertExtra: CGFloat = isAlerting ? 12 : 0 + let toolExtra: CGFloat = displayedToolStatus ? (hasNotch ? 8 : 12) : 0 + let baseWidth: CGFloat = hasNotch ? 30 : compactWingWidth + let naturalWidth = baseWidth + alertExtra + toolExtra + let reservedWidth = NotchWidthMetrics.collapsedRightWingReservedWidth( + showToolStatus: displayedToolStatus, + isAlerting: isAlerting + ) + return max(naturalWidth, reservedWidth) + } + /// Effective island width — applies user scale on both notch and non-notch screens. private var effectiveNotchW: CGFloat { NotchWidthMetrics.effectiveNotchWidth( @@ -61,38 +176,95 @@ struct NotchPanelView: View { ) } + private var collapsedCenterGap: CGFloat { + NotchWidthMetrics.collapsedCenterGap( + effectiveNotchWidth: effectiveNotchW, + physicalNotchWidth: notchW, + hasNotch: hasNotch + ) + } + + private var compactJumpSessionId: String? { + appState.rotatingSessionId ?? appState.activeSessionId ?? appState.sessions.keys.sorted().first + } + /// Total panel width — adapts based on state and screen geometry private var panelWidth: CGFloat { - let nw = effectiveNotchW - let maxWidth = min(620, screenWidth - 40) - if showIdleIndicator { return idleHovered ? nw + compactWingWidth * 2 + 80 : nw + compactWingWidth * 2 } + let nw = shouldShowExpanded ? effectiveNotchW : collapsedCenterGap + if showIdleIndicator { + return NotchWidthMetrics.idlePanelWidth( + notchW: notchW, + compactWingWidth: compactWingWidth, + collapsedWidthScale: collapsedWidthScale, + phase: hoverPhase + ) + } if !isActive { return hasNotch ? nw - 20 : nw } - if shouldShowExpanded { return min(max(nw + 200, 580), maxWidth) } - let wing = compactWingWidth - let extra: CGFloat = appState.status == .idle ? 0 : 20 - // Reserve space for tool status — proportional to screen width - let toolExtra: CGFloat = displayedToolStatus ? (hasNotch ? screenWidth * 0.03 : screenWidth * 0.04) : 0 - return nw + wing * 2 + extra + toolExtra + if shouldShowExpanded { + return NotchWidthMetrics.expandedPanelWidth( + notchW: notchW, + collapsedWidthScale: collapsedWidthScale, + screenWidth: screenWidth + ) + } + let prehoverExtra = shouldShowPrehover ? NotchHoverInteraction.prehoverWidthDelta : 0 + return NotchWidthMetrics.activeCollapsedPanelWidth( + scaledCenterGap: effectiveNotchW, + compactWingWidth: compactWingWidth, + rightWingWidth: collapsedRightWingWidth, + prehoverExtra: prehoverExtra, + minimumVisibleWidth: collapsedCenterGap + compactWingWidth + collapsedRightWingWidth + ) + } + + private func activateCompactSessionIfNeeded() { + guard showBar, !shouldShowExpanded, + let sessionId = compactJumpSessionId, + let session = appState.sessions[sessionId], + !session.isRemote + else { + return + } + TerminalActivator.activate(session: session, sessionId: sessionId) } var body: some View { - VStack(spacing: 0) { + let dragRadius = shouldShowExpanded ? 24.0 : 12.0 + let dragGradient = LinearGradient( + colors: [ + Color(red: 0.15, green: 0.85, blue: 1.00), + Color(red: 1.00, green: 0.20, blue: 0.70) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + let dragStroke = StrokeStyle(lineWidth: 2.0, lineCap: .round, lineJoin: .round, dash: [6, 4]) + let shadowCol = Color(red: 0.15, green: 0.85, blue: 1.00).opacity(0.4) + + return VStack(spacing: 0) { VStack(spacing: 0) { if showBar { // Active: compact bar — wider version when expanded HStack(spacing: 0) { CompactLeftWing(appState: appState, expanded: shouldShowExpanded, mascotSize: mascotSize, hasNotch: hasNotch, showToolStatus: showToolStatus) if hasNotch && !shouldShowExpanded { - Spacer(minLength: effectiveNotchW) + Spacer(minLength: collapsedCenterGap) } else if !shouldShowExpanded && showToolStatus { + Spacer() CompactToolStatus(appState: appState) - Spacer(minLength: 0) + Spacer() } else { - Spacer(minLength: 0) + Spacer() } - CompactRightWing(appState: appState, expanded: shouldShowExpanded, hasNotch: hasNotch) + CompactRightWing( + appState: appState, + expanded: shouldShowExpanded, + hasNotch: hasNotch, + reservedWidth: collapsedRightWingWidth + ) } .frame(height: notchHeight) + .transition(.opacity) } else if showIdleIndicator { IdleIndicatorBar( mascotSize: mascotSize, @@ -100,8 +272,10 @@ struct NotchPanelView: View { notchW: effectiveNotchW, notchHeight: notchHeight, hasNotch: hasNotch, - hovered: idleHovered + expanded: shouldShowIdleExpanded, + isDraggingOver: appState.isDraggingOver ) + .transition(.opacity) } else { // Idle: just the notch shell Spacer() @@ -115,79 +289,123 @@ struct NotchPanelView: View { .frame(height: 0.5) .padding(.horizontal, 12) - switch appState.surface { - case .approvalCard(let sid): - if let pending = appState.pendingPermission { + Group { + switch appState.surface { + case .approvalCard(let sid): + if let pending = appState.pendingPermission { + let session = appState.sessions[sid] + ApprovalBar( + tool: pending.event.toolName ?? "Unknown", + toolInput: pending.event.toolInput, + queuePosition: 1, + queueTotal: appState.permissionQueue.count, + session: session, + sessionId: sid, + appState: appState, + onAllow: { appState.approvePermission(always: false) }, + onAlwaysAllow: { appState.approvePermission(always: true) }, + onDeny: { appState.denyPermission() }, + onDismiss: { appState.dismissPermissionPrompt() } + ) + .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) + } + case .questionCard(let sid): let session = appState.sessions[sid] - ApprovalBar( - tool: pending.event.toolName ?? "Unknown", - toolInput: pending.event.toolInput, - queuePosition: 1, - queueTotal: appState.permissionQueue.count, - session: session, - sessionId: sid, - appState: appState, - onAllow: { appState.approvePermission(always: false) }, - onAlwaysAllow: { appState.approvePermission(always: true) }, - onDeny: { appState.denyPermission() }, - onDismiss: { appState.dismissPermissionPrompt() } - ) - .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) - } - case .questionCard(let sid): - let session = appState.sessions[sid] - if let q = appState.pendingQuestion { - QuestionBar( - question: q.question.question, - options: q.question.options, - descriptions: q.question.descriptions, - allQuestions: q.askUserQuestionState?.items ?? [], - sessionSource: session?.source, - sessionContext: session?.cwd, - queuePosition: 1, - queueTotal: appState.questionQueue.count, - onAnswer: { appState.answerQuestion($0) }, - onAnswerMulti: { appState.answerQuestionMulti($0) }, - onSkip: { appState.skipQuestion() } - ) - .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) - } else if let preview = appState.previewQuestionPayload { - QuestionBar( - question: preview.question, - options: preview.options, - descriptions: preview.descriptions, - allQuestions: [], - sessionSource: session?.source, - sessionContext: session?.cwd, - queuePosition: 1, - queueTotal: 1, - onAnswer: { _ in }, - onAnswerMulti: { _ in }, - onSkip: { } - ) - .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) + if let q = appState.pendingQuestion { + QuestionBar( + question: q.question.question, + options: q.question.options, + descriptions: q.question.descriptions, + allQuestions: q.askUserQuestionState?.items ?? [], + sessionSource: session?.source, + sessionContext: session?.cwd, + queuePosition: 1, + queueTotal: appState.questionQueue.count, + onAnswer: { appState.answerQuestion($0) }, + onAnswerMulti: { appState.answerQuestionMulti($0) }, + onSkip: { appState.skipQuestion() } + ) + .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) + } else if let preview = appState.previewQuestionPayload { + QuestionBar( + question: preview.question, + options: preview.options, + descriptions: preview.descriptions, + allQuestions: [], + sessionSource: session?.source, + sessionContext: session?.cwd, + queuePosition: 1, + queueTotal: 1, + onAnswer: { _ in }, + onAnswerMulti: { _ in }, + onSkip: { } + ) + .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) + } + case .completionCard: + SessionListView(appState: appState, onlySessionId: appState.justCompletedSessionId) + .transition(.blurFade.combined(with: .move(edge: .top))) + case .sessionList: + SessionListView(appState: appState, onlySessionId: nil) + .transition(.blurFade.combined(with: .move(edge: .top))) + case .collapsed: + EmptyView() } - case .completionCard: - SessionListView(appState: appState, onlySessionId: appState.justCompletedSessionId) - .transition(.blurFade.combined(with: .move(edge: .top))) - case .sessionList: - SessionListView(appState: appState, onlySessionId: nil) - .transition(.blurFade.combined(with: .move(edge: .top))) - case .collapsed: - EmptyView() } + .padding(.bottom, 8) } } .frame(width: panelWidth) .clipped() + .contentShape(Rectangle()) + .onTapGesture { + activateCompactSessionIfNeeded() + } + .background( + Color.clear + .padding(.horizontal, 40) + .padding(.bottom, 35) + .contentShape(Rectangle()) + .onDrop(of: [.fileURL], isTargeted: Binding( + get: { appState.isDraggingOver }, + set: { appState.isDraggingOver = $0 } + )) { providers in + appState.handleDroppedFiles(providers) + return true + } + ) + .onChange(of: appState.isDraggingOver) { _, dragging in + if dragging { + withAnimation(NotchAnimation.open) { + hoverPhase = .expanded + if appState.surface == .collapsed { + appState.surface = .sessionList + } + } + } + } + .overlay( + Group { + if appState.isDraggingOver { + RoundedRectangle(cornerRadius: dragRadius) + .strokeBorder(dragGradient, style: dragStroke) + .background( + RoundedRectangle(cornerRadius: dragRadius) + .fill(Color.black.opacity(0.15)) + ) + .shadow(color: shadowCol, radius: 8) + } + } + ) .background( NotchPanelShape( topExtension: shouldShowExpanded ? 14 : 3, - bottomRadius: shouldShowExpanded ? 24 : 12, + bottomRadius: shouldShowExpanded ? 24 : (shouldShowPrehover || shouldShowIdlePrehover ? 13 : 12), minHeight: notchHeight ) .fill(.black) ) + .scaleEffect((shouldShowPrehover || shouldShowIdlePrehover) ? NotchHoverInteraction.prehoverScale : 1, anchor: .top) .offset(y: curtainOffset) .opacity(curtainOpacity) .onChange(of: showToolStatus) { _, newValue in @@ -209,25 +427,15 @@ struct NotchPanelView: View { } } .onAppear { displayedToolStatus = showToolStatus } + .onChange(of: appState.surface) { _, surface in + if case .collapsed = surface { + hoverPhase = .collapsed + } else if surface.isExpanded { + hoverPhase = .expanded + } + } .contentShape(Rectangle()) .onHover { hovering in - // Idle indicator hover — delay un-hover to prevent oscillation when - // the animated width change crosses the mouse position (#52). - if showIdleIndicator { - if hovering { - hoverTimer?.invalidate() - hoverTimer = nil - withAnimation(NotchAnimation.micro) { idleHovered = true } - } else { - hoverTimer?.invalidate() - hoverTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { _ in - Task { @MainActor in - withAnimation(NotchAnimation.micro) { idleHovered = false } - } - } - } - return - } switch appState.surface { case .approvalCard, .questionCard: return case .completionCard: @@ -247,8 +455,6 @@ struct NotchPanelView: View { return default: break } - // Respect collapseOnMouseLeave setting - if !hovering && !SettingsManager.shared.collapseOnMouseLeave { return } // Smart suppress: don't auto-expand when active session's terminal is foreground if hovering && smartSuppress { if let delegate = NSApp.delegate as? AppDelegate, @@ -258,14 +464,19 @@ struct NotchPanelView: View { } } - isHovered = hovering if hovering { - // Delay expansion to avoid accidental triggers + isHovered = true hoverTimer?.invalidate() - hoverTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + hoverTimer = nil + if !appState.surface.isExpanded { + withAnimation(NotchAnimation.hoverPrehover) { + hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .mouseEntered) + } + } + hoverTimer = Timer.scheduledTimer(withTimeInterval: NotchHoverInteraction.expandDelay, repeats: false) { _ in Task { @MainActor in // Guard: mouse may have left during the delay - guard isHovered else { return } + guard isHovered, hoverPhase == .prehover else { return } if hapticOnHover { let performer = NSHapticFeedbackManager.defaultPerformer switch hapticIntensity { @@ -281,22 +492,46 @@ struct NotchPanelView: View { } } withAnimation(NotchAnimation.open) { - appState.surface = .sessionList - appState.cancelCompletionQueue() - if appState.activeSessionId == nil { - appState.activeSessionId = appState.sessions.keys.sorted().first + hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .expandDelayElapsed) + if isActive { + appState.surface = .sessionList + appState.cancelCompletionQueue() + if appState.activeSessionId == nil { + appState.activeSessionId = appState.sessions.keys.sorted().first + } } } } } } else { - // Collapse with brief delay to prevent flicker on accidental mouse-out + isHovered = false hoverTimer?.invalidate() - hoverTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in + hoverTimer = nil + + if hoverPhase == .prehover { + withAnimation(NotchAnimation.hoverPrehover) { + hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .mouseExited) + } + return + } + + if appState.surface.isExpanded { + hoverPhase = .expanded + } + // Idle always collapses on mouse leave; active respects the setting + // WeChat notifications and permissions always collapse on mouse leave to prevent getting stuck expanded. + let isWeChatActive = appState.activeSessionId == "wechat" || appState.activeSessionId == "wechat_permission" + guard showIdleIndicator || SettingsManager.shared.collapseOnMouseLeave || isWeChatActive else { return } + + let delay = isWeChatActive ? 1.5 : NotchHoverInteraction.collapseDelay + hoverTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in Task { @MainActor in guard !isHovered else { return } withAnimation(NotchAnimation.close) { - appState.surface = .collapsed + hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .collapseDelayElapsed) + if isActive { + appState.surface = .collapsed + } } } } @@ -307,7 +542,6 @@ struct NotchPanelView: View { .allowsHitTesting(false) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .animation(NotchAnimation.open, value: appState.surface) } } @@ -332,12 +566,10 @@ private struct CompactLeftWing: View { return appState.sessions[sid] } private var displaySource: String { - // Honor user's configured default mascot whenever nothing is actively - // happening. Covers no-session and all-idle equally (#149) — without - // this, an idle session's source overrides the user preference. - if displayStatus == .idle { return settingsDefaultSource } + // If there's an active session (even if idle), we want to show its specific mascot! + // We only fall back to settingsDefaultSource when there's no session at all. if let s = displaySession?.source { return s } - return appState.primarySource + return settingsDefaultSource } private var displayStatus: AgentStatus { displaySession?.status ?? .idle } private var liveTool: String? { displaySession?.currentTool } @@ -347,7 +579,6 @@ private struct CompactLeftWing: View { var body: some View { HStack(spacing: 6) { if expanded { - AppLogoView(size: 36, showBackground: false) if appState.sessions.count > 1 { HStack(spacing: 1) { ForEach([("all", "ALL"), ("status", "STA"), ("cli", "CLI")], id: \.0) { tag, label in @@ -373,20 +604,15 @@ private struct CompactLeftWing: View { .overlay(Rectangle().stroke(.white.opacity(0.1), lineWidth: 1)) } } else { - MascotView(source: displaySource, status: displayStatus, size: mascotSize) + MascotView(source: displaySource, status: displayStatus, size: mascotSize, isDraggingOver: appState.isDraggingOver) .id(displaySource) .transition(.opacity) .animation(.easeInOut(duration: 0.3), value: displaySource) - // On notch screens, show tool name only (no description, space is tight) - if hasNotch, showToolStatus, let tool = shownTool { - Text(tool) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(toolStatusColor(tool)) - .lineLimit(1) - .fixedSize() - .transition(.opacity) - } + // Note: tool name text (e.g. "bash", "Review") is intentionally NOT shown next + // to the notch in collapsed state — it gets clipped by the physical notch and + // looks broken. The animated mascot alone conveys activity; full tool/desc text + // is shown in the expanded panel. } } .padding(.leading, 6) @@ -417,6 +643,7 @@ private struct CompactRightWing: View { var appState: AppState let expanded: Bool let hasNotch: Bool + let reservedWidth: CGFloat @ObservedObject private var l10n = L10n.shared @AppStorage(SettingsKey.soundEnabled) private var soundEnabled = SettingsDefaults.soundEnabled @AppStorage(SettingsKey.showToolStatus) private var showToolStatus = SettingsDefaults.showToolStatus @@ -425,8 +652,9 @@ private struct CompactRightWing: View { appState.rotatingSessionId ?? appState.activeSessionId ?? appState.sessions.keys.sorted().first } private var projectName: String? { - guard let sid = displaySessionId, let cwd = appState.sessions[sid]?.cwd, !cwd.isEmpty else { return nil } - return (cwd as NSString).lastPathComponent + guard let sid = displaySessionId, let session = appState.sessions[sid], + let cwd = session.cwd, !cwd.isEmpty else { return nil } + return session.displayName } var body: some View { @@ -442,47 +670,33 @@ private struct CompactRightWing: View { NSApplication.shared.terminate(nil) } } else { - // Pending approval/question badge + // Session count first (kept toward the notch side). + HStack(spacing: 1) { + let active = appState.activeSessionCount + let total = appState.totalSessionCount + if active > 0 { + Text("\(active)") + .foregroundStyle(Color(red: 0.4, green: 1.0, blue: 0.5)) + Text("/") + .foregroundStyle(.white.opacity(0.4)) + } + Text("\(total)") + .foregroundStyle(.white.opacity(0.9)) + } + .font(.system(size: showToolStatus ? 12 : 13, weight: showToolStatus ? .semibold : .bold, design: .monospaced)) + + // Pending approval/question (incl. WeChat) bell — placed LAST so it sits at the + // outer edge of the right wing, clear of the physical notch instead of being + // clipped by it (#3). if appState.status == .waitingApproval || appState.status == .waitingQuestion { Image(systemName: "bell.fill") .font(.system(size: 9, weight: .bold)) .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) .symbolEffect(.pulse, options: .repeating) } - - if showToolStatus { - // Detailed mode: session count (project name is shown in center on non-notch) - HStack(spacing: 1) { - let active = appState.activeSessionCount - let total = appState.totalSessionCount - if active > 0 { - Text("\(active)") - .foregroundStyle(Color(red: 0.4, green: 1.0, blue: 0.5)) - Text("/") - .foregroundStyle(.white.opacity(0.4)) - } - Text("\(total)") - .foregroundStyle(.white.opacity(0.9)) - } - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - } else { - // Simple mode: original session count only - HStack(spacing: 1) { - let active = appState.activeSessionCount - let total = appState.totalSessionCount - if active > 0 { - Text("\(active)") - .foregroundStyle(Color(red: 0.4, green: 1.0, blue: 0.5)) - Text("/") - .foregroundStyle(.white.opacity(0.4)) - } - Text("\(total)") - .foregroundStyle(.white.opacity(0.9)) - } - .font(.system(size: 13, weight: .bold, design: .monospaced)) - } } } + .frame(width: expanded ? nil : reservedWidth, alignment: .trailing) .padding(.trailing, 6) } } @@ -520,8 +734,8 @@ private struct CompactToolStatus: View { private var liveDesc: String? { displaySession?.toolDescription } private var displayStatus: AgentStatus { displaySession?.status ?? .idle } private var projectName: String? { - guard let cwd = displaySession?.cwd, !cwd.isEmpty else { return nil } - return (cwd as NSString).lastPathComponent + guard let session = displaySession, let cwd = session.cwd, !cwd.isEmpty else { return nil } + return session.displayName } @State private var shownTool: String? @@ -637,28 +851,26 @@ private struct IdleIndicatorBar: View { let notchW: CGFloat let notchHeight: CGFloat let hasNotch: Bool - let hovered: Bool + let expanded: Bool + let isDraggingOver: Bool @ObservedObject private var l10n = L10n.shared @AppStorage(SettingsKey.soundEnabled) private var soundEnabled = SettingsDefaults.soundEnabled + @AppStorage(SettingsKey.defaultSource) private var settingsDefaultSource = SettingsDefaults.defaultSource var body: some View { HStack(spacing: 0) { // Left: mascot HStack(spacing: 6) { - MascotView(source: "claude", status: .idle, size: mascotSize) - .opacity(hovered ? 0.9 : 0.5) + MascotView(source: settingsDefaultSource, status: .idle, size: mascotSize, isDraggingOver: isDraggingOver) + .opacity(expanded ? 0.9 : 0.5) } .padding(.leading, 6) Spacer(minLength: hasNotch ? notchW : 0) // Right: expanded shows text + buttons, collapsed shows nothing - if hovered { + if expanded { HStack(spacing: 8) { - Text("0") - .font(.system(size: 13, weight: .bold, design: .monospaced)) - .foregroundStyle(.white.opacity(0.4)) - HStack(spacing: 4) { NotchIconButton(icon: soundEnabled ? "speaker.wave.2" : "speaker.slash", tooltip: soundEnabled ? l10n["mute"] : l10n["enable_sound_tooltip"]) { soundEnabled.toggle() @@ -676,7 +888,7 @@ private struct IdleIndicatorBar: View { } } .frame(height: notchHeight) - .animation(NotchAnimation.micro, value: hovered) + .animation(NotchAnimation.open, value: expanded) } } @@ -876,61 +1088,110 @@ private struct ApprovalBar: View { } var body: some View { - VStack(spacing: 8) { - // Tool name + file context - HStack(spacing: 6) { - Text("!") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) - Text(tool) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) - if let server = serverName { - Text("(\(server))") - .font(.system(size: 9)) - .foregroundStyle(Color(red: 0.6, green: 0.7, blue: 0.9)) - } - if let name = fileName { - Text(name) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white.opacity(0.6)) + HStack(spacing: 0) { + // Left guide line (shining neon orange vertical bar) + RoundedRectangle(cornerRadius: 1.5) + .fill( + LinearGradient( + colors: [Color(red: 1.0, green: 0.65, blue: 0.15), Color(red: 0.9, green: 0.35, blue: 0.1)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 3) + .padding(.vertical, 8) + .padding(.leading, 8) + + VStack(spacing: 8) { + // Tool name + file context + HStack(spacing: 6) { + // Shining Pill tag for the tool + HStack(spacing: 4) { + Image(systemName: "exclamationmark.shield.fill") + .font(.system(size: 9)) + Text(tool.uppercased()) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + } + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(LinearGradient(colors: [Color(red: 1.0, green: 0.6, blue: 0.2), Color(red: 0.85, green: 0.4, blue: 0.1)], startPoint: .leading, endPoint: .trailing)) + ) + + if let server = serverName { + Text("(\(server))") + .font(.system(size: 9.5, design: .rounded)) + .foregroundStyle(Color(red: 0.5, green: 0.8, blue: 1.0)) + } + + if let name = fileName { + HStack(spacing: 3) { + Image(systemName: "doc.text") + .font(.system(size: 9)) + Text(name) + .font(.system(size: 9.5, weight: .semibold, design: .rounded)) + } + .foregroundStyle(.white.opacity(0.72)) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background(RoundedRectangle(cornerRadius: 4).fill(.white.opacity(0.08))) + } + + if queueTotal > 1 { + Text("\(queuePosition)/\(queueTotal)") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.8)) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background(Color.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + Spacer() } - if queueTotal > 1 { - Text("\(queuePosition)/\(queueTotal)") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white.opacity(0.5)) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(Color.white.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 3)) + .padding(.horizontal, 14) + .contentShape(Rectangle()) + .onTapGesture { handleCardClick() } + + // Tool-specific detail view with terminal background + if toolInput != nil { + ApprovalToolDetailView(tool: tool, toolInput: toolInput) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(0.4)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(.white.opacity(0.08), lineWidth: 1) + ) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + .onTapGesture { handleCardClick() } } - Spacer() - } - .padding(.horizontal, 14) - .contentShape(Rectangle()) - .onTapGesture { handleCardClick() } - // Tool-specific detail view - if toolInput != nil { - ApprovalToolDetailView(tool: tool, toolInput: toolInput) - .padding(.horizontal, 14) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white.opacity(0.04)) - .contentShape(Rectangle()) - .onTapGesture { handleCardClick() } - } - - // Pixel-style buttons - HStack(spacing: 6) { - PixelButton(label: L10n.shared["deny"], fg: .white.opacity(0.95), bg: Color(red: 0.45, green: 0.12, blue: 0.12), border: Color(red: 0.7, green: 0.25, blue: 0.25), action: onDeny) - PixelButton(label: L10n.shared["dismiss"], fg: .white.opacity(0.95), bg: Color(red: 0.25, green: 0.25, blue: 0.25), border: Color.white.opacity(0.28), action: onDismiss) - PixelButton(label: L10n.shared["allow_once"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), border: Color(red: 0.28, green: 0.62, blue: 0.32), action: onAllow) - PixelButton(label: L10n.shared["always"], fg: .white.opacity(0.95), bg: Color(red: 0.14, green: 0.28, blue: 0.52), border: Color(red: 0.28, green: 0.48, blue: 0.82), action: onAlwaysAllow) + // Premium buttons + HStack(spacing: 6) { + PremiumButton(label: L10n.shared["deny"], fg: .white.opacity(0.95), bg: Color(red: 0.45, green: 0.12, blue: 0.12), border: Color(red: 0.7, green: 0.25, blue: 0.25), action: onDeny) + PremiumButton(label: L10n.shared["dismiss"], fg: .white.opacity(0.95), bg: Color(red: 0.25, green: 0.25, blue: 0.25), border: Color.white.opacity(0.28), action: onDismiss) + PremiumButton(label: L10n.shared["allow_once"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), border: Color(red: 0.28, green: 0.62, blue: 0.32), action: onAllow) + PremiumButton(label: L10n.shared["always"], fg: .white.opacity(0.95), bg: Color(red: 0.14, green: 0.28, blue: 0.52), border: Color(red: 0.28, green: 0.48, blue: 0.82), action: onAlwaysAllow) + } + .padding(.horizontal, 10) } - .padding(.horizontal, 14) } - .padding(.vertical, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.01)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(.white.opacity(0.04), lineWidth: 1) + ) + ) .offset(x: failureShakeOffset) .onDisappear { jumpValidationTask?.cancel() @@ -1107,13 +1368,17 @@ private struct QuestionBar: View { .lineLimit(3) Spacer() if allQuestions.count > 1 { - Text("\(currentQuestionIndex + 1)/\(allQuestions.count)") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white.opacity(0.5)) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(Color.white.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 3)) + HStack(spacing: 4) { + ForEach(0.. 1 { Text("\(queuePosition)/\(queueTotal)") @@ -1152,13 +1417,13 @@ private struct QuestionBar: View { // "Other" text input if showOtherInput { - HStack(spacing: 6) { + HStack(spacing: 8) { Text(">") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundStyle(Color(red: 0.3, green: 0.85, blue: 0.4)) TextField(L10n.shared["type_answer"], text: $otherText) .textFieldStyle(.plain) - .font(.system(size: 10.5)) + .font(.system(size: 10.5, design: .rounded)) .foregroundStyle(.white) .focused($otherFocused) .onSubmit { @@ -1167,49 +1432,58 @@ private struct QuestionBar: View { } } } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.white.opacity(0.05)) - .cornerRadius(4) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.black.opacity(0.3)) + ) .overlay( - RoundedRectangle(cornerRadius: 4) - .strokeBorder(Color.white.opacity(0.1), lineWidth: 1) + RoundedRectangle(cornerRadius: 8) + .strokeBorder(otherFocused ? cyan : .white.opacity(0.12), lineWidth: 1) ) + .shadow(color: cyan.opacity(otherFocused ? 0.25 : 0.0), radius: 6, x: 0, y: 2) .padding(.horizontal, 14) .onAppear { otherFocused = true } + .animation(.spring(response: 0.2, dampingFraction: 0.7), value: otherFocused) } } .padding(.horizontal, 14) } else { // No options — text input only - HStack(spacing: 6) { + HStack(spacing: 8) { Text(">") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundStyle(Color(red: 0.3, green: 0.85, blue: 0.4)) TextField(L10n.shared["type_answer"], text: $textInput) .textFieldStyle(.plain) - .font(.system(size: 10.5)) + .font(.system(size: 10.5, design: .rounded)) .foregroundStyle(.white) .focused($isFocused) .onSubmit { if !textInput.isEmpty { advanceWithAnswer(textInput) } } } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.white.opacity(0.05)) - .cornerRadius(4) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.black.opacity(0.3)) + ) .overlay( - RoundedRectangle(cornerRadius: 4) - .strokeBorder(Color.white.opacity(0.1), lineWidth: 1) + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isFocused ? cyan : .white.opacity(0.12), lineWidth: 1) ) + .shadow(color: cyan.opacity(isFocused ? 0.25 : 0.0), radius: 6, x: 0, y: 2) + .padding(.horizontal, 14) + .animation(.spring(response: 0.2, dampingFraction: 0.7), value: isFocused) .padding(.horizontal, 14) } // Buttons HStack(spacing: 6) { if currentQuestionIndex > 0 { - PixelButton( + PremiumButton( label: L10n.shared["back"], fg: .white.opacity(0.6), bg: Color.white.opacity(0.06), @@ -1217,7 +1491,7 @@ private struct QuestionBar: View { action: goBack ) } - PixelButton( + PremiumButton( label: L10n.shared["skip"], fg: .white.opacity(0.6), bg: Color.white.opacity(0.06), @@ -1225,7 +1499,7 @@ private struct QuestionBar: View { action: onSkip ) if item.multiSelect { - PixelButton( + PremiumButton( label: L10n.shared["confirm"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), @@ -1233,7 +1507,7 @@ private struct QuestionBar: View { action: confirmMultiSelect ) } else if item.payload.options == nil || item.payload.options?.isEmpty == true { - PixelButton( + PremiumButton( label: L10n.shared["submit"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), @@ -1241,7 +1515,7 @@ private struct QuestionBar: View { action: { if !textInput.isEmpty { advanceWithAnswer(textInput) } } ) } else if showOtherInput && !item.multiSelect { - PixelButton( + PremiumButton( label: L10n.shared["submit"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), @@ -1379,7 +1653,7 @@ private struct QuestionBar: View { } HStack(spacing: 6) { - PixelButton( + PremiumButton( label: L10n.shared["skip"], fg: .white.opacity(0.6), bg: Color.white.opacity(0.06), @@ -1387,7 +1661,7 @@ private struct QuestionBar: View { action: onSkip ) if options == nil || options?.isEmpty == true { - PixelButton( + PremiumButton( label: L10n.shared["submit"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), @@ -1413,37 +1687,60 @@ private struct MultiSelectRow: View { var body: some View { Button(action: action) { - HStack(spacing: 8) { - Image(systemName: isChecked ? "checkmark.square.fill" : "square") - .font(.system(size: 11)) - .foregroundStyle(isChecked ? accent : .white.opacity(0.4)) - .frame(width: 14) + HStack(spacing: 10) { + // Circular checkbox + ZStack { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(isChecked ? accent : .white.opacity(0.3), lineWidth: 1.5) + .frame(width: 14, height: 14) + + if isChecked { + RoundedRectangle(cornerRadius: 3) + .fill(accent) + .frame(width: 8, height: 8) + .shadow(color: accent.opacity(0.5), radius: 2) + } + } + VStack(alignment: .leading, spacing: 2) { Text(label) - .font(.system(size: 10.5, weight: hovering || isChecked ? .semibold : .regular)) - .foregroundStyle(.white.opacity(hovering || isChecked ? 1 : 0.75)) + .font(.system(size: 10.5, weight: hovering || isChecked ? .semibold : .regular, design: .rounded)) + .foregroundStyle(.white.opacity(hovering || isChecked ? 1.0 : 0.8)) if let description, !description.isEmpty { Text(description) .font(.system(size: 9)) - .foregroundStyle(.white.opacity(0.45)) + .foregroundStyle(.white.opacity(0.55)) .lineLimit(2) } } Spacer() } - .padding(.horizontal, 10) - .padding(.vertical, 7) + .padding(.horizontal, 12) + .padding(.vertical, 8) .background( - RoundedRectangle(cornerRadius: 4) - .fill(isChecked ? accent.opacity(0.08) : (hovering ? Color.white.opacity(0.08) : Color.white.opacity(0.03))) + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(isChecked ? accent.opacity(0.06) : (hovering ? Color.white.opacity(0.06) : Color.white.opacity(0.02))) + + // Left glow line + if isChecked { + HStack { + RoundedRectangle(cornerRadius: 1) + .fill(accent) + .frame(width: 2) + .padding(.vertical, 6) + Spacer() + } + } + } ) .overlay( - RoundedRectangle(cornerRadius: 4) - .strokeBorder(isChecked ? accent.opacity(0.4) : (hovering ? accent.opacity(0.2) : Color.clear), lineWidth: 1) + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isChecked ? accent.opacity(0.25) : (hovering ? .white.opacity(0.08) : Color.clear), lineWidth: 1) ) } .buttonStyle(.plain) - .onHover { h in withAnimation(NotchAnimation.micro) { hovering = h } } + .onHover { h in withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) { hovering = h } } } } @@ -1460,78 +1757,138 @@ private struct OptionRow: View { var body: some View { Button(action: action) { - HStack(spacing: 8) { - // Selector arrow - Text(hovering ? "▸" : " ") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(accent) - .frame(width: 10) - // Number (or ellipsis for "Other") - if index > 0 { - Text("\(index).") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(accent.opacity(hovering ? 1 : 0.6)) - } else { - Text("…") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(accent.opacity(hovering ? 1 : 0.6)) + HStack(spacing: 10) { + // Sleek circular index badge + ZStack { + Circle() + .strokeBorder(isSelected ? accent : .white.opacity(0.2), lineWidth: 1.2) + .background(Circle().fill(isSelected ? accent.opacity(0.12) : .white.opacity(0.04))) + .frame(width: 16, height: 16) + + if index > 0 { + Text("\(index)") + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundStyle(isSelected ? accent : .white.opacity(0.6)) + } else { + Text("…") + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundStyle(isSelected ? accent : .white.opacity(0.6)) + } } - // Label + Description + VStack(alignment: .leading, spacing: 2) { Text(label) - .font(.system(size: 10.5, weight: hovering ? .semibold : .regular)) - .foregroundStyle(.white.opacity(hovering ? 1 : 0.75)) + .font(.system(size: 10.5, weight: hovering || isSelected ? .semibold : .regular, design: .rounded)) + .foregroundStyle(.white.opacity(hovering || isSelected ? 1.0 : 0.8)) if let description, !description.isEmpty { Text(description) .font(.system(size: 9)) - .foregroundStyle(.white.opacity(0.45)) + .foregroundStyle(.white.opacity(0.55)) .lineLimit(2) } } Spacer() } - .padding(.horizontal, 10) - .padding(.vertical, 7) + .padding(.horizontal, 12) + .padding(.vertical, 8) .background( - RoundedRectangle(cornerRadius: 4) - .fill(hovering ? Color.white.opacity(0.08) : Color.white.opacity(0.03)) + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? accent.opacity(0.06) : (hovering ? Color.white.opacity(0.06) : Color.white.opacity(0.02))) + + // Left glow line + if isSelected { + HStack { + RoundedRectangle(cornerRadius: 1) + .fill(accent) + .frame(width: 2) + .padding(.vertical, 6) + Spacer() + } + } + } ) .overlay( - RoundedRectangle(cornerRadius: 4) - .strokeBorder(hovering ? accent.opacity(0.4) : Color.clear, lineWidth: 1) + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isSelected ? accent.opacity(0.25) : (hovering ? .white.opacity(0.08) : Color.clear), lineWidth: 1) ) } .buttonStyle(.plain) - .onHover { h in withAnimation(NotchAnimation.micro) { hovering = h } } + .onHover { h in withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) { hovering = h } } } } -private struct PixelButton: View { +private struct PremiumButton: View { let label: String let fg: Color let bg: Color let border: Color let action: () -> Void + @State private var hovering = false + @State private var isPressed = false var body: some View { Button(action: action) { - Text(label) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(fg) - .frame(maxWidth: .infinity) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(hovering ? bg.opacity(1.5) : bg) - ) - .overlay( - RoundedRectangle(cornerRadius: 4) - .strokeBorder(hovering ? border : border.opacity(0.4), lineWidth: 1) - ) + HStack(spacing: 5) { + if let iconName = determineIcon(label: label) { + Image(systemName: iconName) + .font(.system(size: 10, weight: .bold)) + } + Text(label) + .font(.system(size: 10, weight: .bold, design: .rounded)) + } + .foregroundStyle(fg) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + .background( + ZStack { + // Base color + RoundedRectangle(cornerRadius: 6) + .fill(bg) + + // Shiny top highlight + RoundedRectangle(cornerRadius: 6) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.12), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + ) + + // Hover overlay + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(hovering ? 0.08 : 0.0)) + } + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(hovering ? border : border.opacity(0.4), lineWidth: 1) + ) + .shadow(color: bg.opacity(hovering ? 0.3 : 0.0), radius: 6, x: 0, y: 2) + .scaleEffect(isPressed ? 0.96 : 1.0) + .animation(.spring(response: 0.2, dampingFraction: 0.6), value: hovering) + .animation(.spring(response: 0.15, dampingFraction: 0.55), value: isPressed) } .buttonStyle(.plain) - .onHover { h in withAnimation(NotchAnimation.micro) { hovering = h } } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in isPressed = true } + .onEnded { _ in isPressed = false } + ) + .onHover { h in hovering = h } + } + + private func determineIcon(label: String) -> String? { + let l = label.lowercased() + if l.contains("deny") || l.contains("拒绝") { return "xmark.circle.fill" } + if l.contains("dismiss") || l.contains("忽略") || l.contains("跳过") || l.contains("skip") { return "bell.slash.fill" } + if l.contains("once") || l.contains("一次") { return "checkmark.circle.fill" } + if l.contains("always") || l.contains("始终") { return "lock.open.fill" } + if l.contains("submit") || l.contains("确认") || l.contains("确定") || l.contains("ok") || l.contains("confirm") { return "paperplane.fill" } + if l.contains("back") || l.contains("返回") { return "chevron.left" } + return nil } } @@ -1593,6 +1950,7 @@ private struct SessionListView: View { ("qwen", "Qwen Code"), ("kimi", "Kimi Code CLI"), ("opencode", "OpenCode"), + ("wechat", "微信"), ] var result: [(String, String?, [String])] = [] var seen = Set() @@ -1739,27 +2097,31 @@ private struct SessionIdentityLine: View { ) .layoutPriority(2) - if let sessionLabel = session.sessionLabel { - Text("#\(sessionLabel)") - .font(.system(size: sessionFontSize, weight: .medium, design: .monospaced)) - .foregroundStyle(sessionColor) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) + // WeChat is a notification pseudo-session, not a coding session — the raw session-id + // badge ("#wech") is noise here, so just show the clean "wechat" name. + if session.source != "wechat" { + if let sessionLabel = session.sessionLabel { + Text("#\(sessionLabel)") + .font(.system(size: sessionFontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(sessionColor) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) - Text("·") - .font(.system(size: sessionFontSize, weight: .semibold, design: .monospaced)) - .foregroundStyle(dividerColor) + Text("·") + .font(.system(size: sessionFontSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(dividerColor) - Text("#\(shortSessionId(displaySessionId))") - .font(.system(size: sessionFontSize, weight: .medium, design: .monospaced)) - .foregroundStyle(sessionColor.opacity(0.6)) - .fixedSize() - } else { - Text("#\(shortSessionId(displaySessionId))") - .font(.system(size: sessionFontSize, weight: .medium, design: .monospaced)) - .foregroundStyle(sessionColor.opacity(0.6)) - .fixedSize() + Text("#\(shortSessionId(displaySessionId))") + .font(.system(size: sessionFontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(sessionColor.opacity(0.6)) + .fixedSize() + } else { + Text("#\(shortSessionId(displaySessionId))") + .font(.system(size: sessionFontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(sessionColor.opacity(0.6)) + .fixedSize() + } } } } @@ -1883,6 +2245,32 @@ func approvalInlineSummary(tool: String, toolDescription: String?, toolInput: [S return nil } +private struct WeChatNotificationLines: View { + let text: String + let fontSize: CGFloat + + private var lines: [String] { + text.split(separator: "\n", omittingEmptySubsequences: true).map(String.init) + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(lines.prefix(5).enumerated()), id: \.offset) { _, line in + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text("•") + .font(.system(size: max(9, fontSize - 1), weight: .bold, design: .monospaced)) + .foregroundStyle(Color(red: 0.2, green: 0.82, blue: 0.2).opacity(0.9)) + Text(line) + .font(.system(size: fontSize, design: .monospaced)) + .foregroundStyle(.white.opacity(0.78)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + } +} + private struct SessionCard: View { var appState: AppState let sessionId: String @@ -1902,6 +2290,9 @@ private struct SessionCard: View { appState.permissionQueue.firstIndex { ($0.event.sessionId ?? "default") == sessionId } } private var isActiveApproval: Bool { approvalQueueIndex == 0 } + private var canDismissFromIsland: Bool { + session.source == "wechat" || (session.status != .waitingApproval && session.status != .waitingQuestion) + } private var statusNameColor: Color { if session.status == .idle && session.interrupted { return Color(red: 1.0, green: 0.45, blue: 0.35) @@ -1922,26 +2313,51 @@ private struct SessionCard: View { ) -> some View { Button(action: action) { Text(label) - .font(.system(size: max(10, fontSize - 1), weight: .semibold, design: .monospaced)) + .font(.system(size: max(10, fontSize - 1), weight: .bold, design: .rounded)) .foregroundStyle(fg) - .padding(.horizontal, 8) + .padding(.horizontal, 10) .padding(.vertical, 5) .background( - RoundedRectangle(cornerRadius: 5) - .fill(bg.opacity(enabled ? 1 : 0.35)) + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(bg.opacity(enabled ? 1.0 : 0.35)) + + if enabled { + RoundedRectangle(cornerRadius: 6) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.12), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + ) + } + } ) .overlay( - RoundedRectangle(cornerRadius: 5) - .strokeBorder(.white.opacity(enabled ? 0.25 : 0.12), lineWidth: 1) + RoundedRectangle(cornerRadius: 6) + .strokeBorder(.white.opacity(enabled ? 0.22 : 0.1), lineWidth: 1) ) + .shadow(color: bg.opacity(enabled ? 0.25 : 0.0), radius: 4, x: 0, y: 1.5) } .buttonStyle(.plain) .disabled(!enabled) - .opacity(enabled ? 1 : 0.55) + .opacity(enabled ? 1 : 0.5) } var body: some View { - HStack(alignment: .center, spacing: 8) { + if session.source == "drops" { + DropsCard(appState: appState, session: session) + } else if session.source == "pomodoro" { + PomodoroCard(appState: appState, session: session) + } else if session.source == "media" { + MediaCard(appState: appState, session: session) + } else if session.source == "calendar" { + CalendarCard(appState: appState, session: session) + } else if session.source == "wechat_permission" { + WeChatPermissionCard(appState: appState, session: session) + } else { + HStack(alignment: .center, spacing: 8) { // Column 1: Character + subagent icons VStack(spacing: 3) { MascotView(source: session.source, status: session.status, size: 32) @@ -1978,7 +2394,8 @@ private struct SessionCard: View { sessionColor: .white.opacity(0.76), dividerColor: .white.opacity(0.28) ) - Spacer(minLength: 8) + Spacer() + HStack(spacing: 4) { if let remote = session.remoteDisplayName { @@ -1995,6 +2412,26 @@ private struct SessionCard: View { } SessionTag(timeAgo(session.startTime)) TerminalBadge(session: session) + if canDismissFromIsland { + Button { + if session.source == "wechat" { + appState.dismissWeChatFromIsland() + } else { + appState.dismissSessionFromIsland(sessionId) + } + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white.opacity(0.72)) + .frame(width: 18, height: 18) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color.white.opacity(hovering ? 0.13 : 0.07)) + ) + } + .buttonStyle(.plain) + .help(L10n.shared["dismiss"]) + } } } @@ -2108,7 +2545,9 @@ private struct SessionCard: View { } // Working indicator: show what AI is doing right now - if session.status != .idle { + if session.status != .idle, session.source == "wechat", let desc = session.toolDescription { + WeChatNotificationLines(text: desc, fontSize: fontSize) + } else if session.status != .idle { HStack(spacing: 4) { Text("$") .font(.system(size: fontSize, weight: .bold, design: .monospaced)) @@ -2145,6 +2584,7 @@ private struct SessionCard: View { jumpValidationTask?.cancel() jumpValidationTask = nil } + } } private func handleSessionClick() { @@ -2210,6 +2650,438 @@ private struct SessionCard: View { } } +// MARK: - WeChat Permission Card View +private struct WeChatPermissionCard: View { + var appState: AppState + let session: SessionSnapshot + @AppStorage(SettingsKey.contentFontSize) private var contentFontSize = SettingsDefaults.contentFontSize + private var fontSize: CGFloat { CGFloat(contentFontSize) } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + // Mascot Column + VStack { + MascotView(source: "wechat", status: .waitingQuestion, size: 32) + } + .frame(width: 36) + + // Content Column + VStack(alignment: .leading, spacing: 6) { + // Header line + HStack { + Text("微信监控") + .font(.system(size: fontSize + 1, weight: .bold, design: .rounded)) + .foregroundStyle(Color(red: 1.00, green: 0.60, blue: 0.20)) // Warning Orange + + Text("• 权限警告") + .font(.system(size: fontSize - 1, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) + + Spacer() + } + + Text("系统重签/安全限制导致微信“辅助功能”权限丢失,灵动岛无法读取消息。") + .font(.system(size: fontSize - 1, weight: .regular)) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(2) + + HStack(spacing: 8) { + Button { + appState.openWeChatAccessibilitySettings() + } label: { + HStack(spacing: 4) { + Image(systemName: "hand.raised.fill") + .font(.system(size: 10)) + Text("去系统设置开启权限") + .font(.system(size: fontSize - 1, weight: .semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(LinearGradient(colors: [Color(red: 1.00, green: 0.60, blue: 0.20), Color(red: 0.85, green: 0.40, blue: 0.10)], startPoint: .top, endPoint: .bottom)) + ) + } + .buttonStyle(.plain) + + Button { + appState.dismissWeChatPermissionAlert() + } label: { + Text("忽略") + .font(.system(size: fontSize - 1, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(.white.opacity(0.08)) + ) + } + .buttonStyle(.plain) + } + .padding(.top, 4) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.02)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(.white.opacity(0.06), lineWidth: 1) + ) + ) + } +} + +// MARK: - Drops Card View for NotchDrop file management +private struct DropsCard: View { + var appState: AppState + let session: SessionSnapshot + @State private var hoveringIndex: Int? = nil + @AppStorage(SettingsKey.contentFontSize) private var contentFontSize = SettingsDefaults.contentFontSize + private var fontSize: CGFloat { CGFloat(contentFontSize) } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + // Mascot Column + VStack { + MascotView(source: "drops", status: .waitingApproval, size: 32) + } + .frame(width: 36) + + // Content Column + VStack(alignment: .leading, spacing: 8) { + // Header line + HStack { + Text("灵动收纳袋") + .font(.system(size: fontSize + 2, weight: .bold, design: .monospaced)) + .foregroundStyle(Color(red: 0.15, green: 0.85, blue: 1.00)) // Neon Cyan + + Text("• Drops") + .font(.system(size: fontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.5)) + + Spacer() + + Text("\(appState.droppedFiles.count) 个文件") + .font(.system(size: fontSize - 1, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.6)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(.white.opacity(0.1))) + } + + // File List + VStack(spacing: 4) { + ForEach(Array(appState.droppedFiles.enumerated()), id: \.element) { idx, url in + DropFileRow(idx: idx, url: url, appState: appState, fontSize: fontSize, hoveringIndex: $hoveringIndex) + } + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.02)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(.white.opacity(0.06), lineWidth: 1) + ) + ) + } +} + +private struct DropFileRow: View { + let idx: Int + let url: URL + var appState: AppState + let fontSize: CGFloat + @Binding var hoveringIndex: Int? + + var body: some View { + let openColor = Color(red: 0.15, green: 0.85, blue: 1.00) + let shareColor = Color(red: 0.3, green: 0.85, blue: 0.4) + let saveColor = Color(red: 0.95, green: 0.65, blue: 0.15) + let deleteColor = Color(red: 1.0, green: 0.35, blue: 0.35) + + HStack(spacing: 8) { + // File Icon + let icon = NSWorkspace.shared.icon(forFile: url.path) + Image(nsImage: icon) + .resizable() + .frame(width: 16, height: 16) + + // File Name + VStack(alignment: .leading, spacing: 1) { + Text(url.lastPathComponent) + .font(.system(size: fontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.9)) + .lineLimit(1) + .truncationMode(.middle) + + Text(formatFileSize(url)) + .font(.system(size: fontSize - 2, design: .monospaced)) + .foregroundStyle(.white.opacity(0.4)) + } + + Spacer() + + // Actions + HStack(spacing: 6) { + // Open + Button { + NSWorkspace.shared.open(url) + } label: { + Image(systemName: "arrow.up.right.circle.fill") + .foregroundStyle(openColor) + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .help("打开文件") + + // AirDrop + Button { + let sender = NSApplication.shared.keyWindow?.contentView ?? NSView() + appState.shareDroppedFile(url, sender: sender) + } label: { + Image(systemName: "square.and.arrow.up.fill") + .foregroundStyle(shareColor) + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .help("分享 / AirDrop") + + // Save As + Button { + appState.saveDroppedFileAs(url) + } label: { + Image(systemName: "tray.and.arrow.down.fill") + .foregroundStyle(saveColor) + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .help("另存为...") + + // Delete + Button { + withAnimation { + appState.deleteDroppedFile(url) + } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(deleteColor) + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .help("删除") + } + .opacity(hoveringIndex == idx ? 1.0 : 0.6) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(hoveringIndex == idx ? 0.08 : 0.03)) + ) + .onHover { isHover in + hoveringIndex = isHover ? idx : nil + } + .onDrag { + let provider = NSItemProvider() + provider.suggestedName = url.deletingPathExtension().lastPathComponent + + // Register NSURL so macOS handles naming and system-level file-url metadata correctly + provider.registerObject(url as NSURL, visibility: .all) + + // Register specific UTI for proper file transfer handling in Finder/WeChat + let fileExtension = url.pathExtension + let fileUTI = UTType(filenameExtension: fileExtension) ?? .data + provider.registerFileRepresentation(forTypeIdentifier: fileUTI.identifier, fileOptions: [], visibility: .all) { completionHandler in + completionHandler(url, false, nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + withAnimation { + appState.deleteDroppedFile(url) + } + } + return nil + } + return provider + } preview: { + HStack(spacing: 8) { + let icon = NSWorkspace.shared.icon(forFile: url.path) + Image(nsImage: icon) + .resizable() + .frame(width: 24, height: 24) + + Text(url.lastPathComponent) + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundStyle(.white) + .lineLimit(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(red: 0.1, green: 0.1, blue: 0.1).opacity(0.95)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3) + ) + } + } + + private func formatFileSize(_ url: URL) -> String { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attributes[.size] as? Int64 else { + return "" + } + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: size) + } +} + +// MARK: - MiniSoundWaveView - Futuristic soundwave for card middle space +private struct MiniSoundWaveView: View { + let source: String + let status: AgentStatus + + private var waveGradients: [Gradient] { + if source == "wechat" { + return [ + Gradient(colors: [Color(red: 0.20, green: 0.85, blue: 0.35).opacity(0.6), Color(red: 0.30, green: 0.95, blue: 0.50).opacity(0.2)]), + Gradient(colors: [Color(red: 0.30, green: 0.95, blue: 0.50).opacity(0.5), Color(red: 0.10, green: 0.70, blue: 0.25).opacity(0.1)]) + ] + } + switch status { + case .processing, .running: + return [ + Gradient(colors: [Color(red: 0.15, green: 0.85, blue: 1.00).opacity(0.6), Color(red: 0.55, green: 0.25, blue: 1.00).opacity(0.2)]), + Gradient(colors: [Color(red: 1.00, green: 0.20, blue: 0.70).opacity(0.5), Color(red: 0.55, green: 0.25, blue: 1.00).opacity(0.1)]) + ] + case .waitingApproval, .waitingQuestion: + return [ + Gradient(colors: [Color(red: 1.00, green: 0.60, blue: 0.20).opacity(0.6), Color(red: 1.00, green: 0.40, blue: 0.10).opacity(0.2)]), + Gradient(colors: [Color(red: 1.00, green: 0.40, blue: 0.10).opacity(0.5), Color(red: 0.80, green: 0.20, blue: 0.00).opacity(0.1)]) + ] + case .idle: + return [ + Gradient(colors: [Color.white.opacity(0.15), Color.white.opacity(0.05)]), + Gradient(colors: [Color.white.opacity(0.10), Color.white.opacity(0.02)]) + ] + } + } + + private var isProcessing: Bool { + if source == "wechat" { return true } + switch status { + case .processing, .running, .waitingApproval, .waitingQuestion: + return true + default: + return false + } + } + + var body: some View { + TimelineView(.animation) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + Canvas { c, sz in + let midY = sz.height / 2 + let width = sz.width + + let baseAmp: CGFloat = isProcessing ? 11.0 : 2.5 + let speed: Double = isProcessing ? 5.5 : 1.2 + let freq: CGFloat = isProcessing ? 0.04 : 0.015 + + // Draw 2 overlapping waves + drawSingleWave( + context: c, + width: width, + midY: midY, + time: t, + amplitude: baseAmp, + speed: speed, + frequency: freq, + phaseOffset: 0.0, + gradient: waveGradients[0], + lineWidth: 1.8 + ) + + drawSingleWave( + context: c, + width: width, + midY: midY, + time: t, + amplitude: baseAmp * 0.7, + speed: speed * 1.3, + frequency: freq * 0.85, + phaseOffset: .pi * 0.5, + gradient: waveGradients[1], + lineWidth: 1.2 + ) + } + .frame(height: 24) + } + } + + private func drawSingleWave( + context c: GraphicsContext, + width: CGFloat, + midY: CGFloat, + time: Double, + amplitude: CGFloat, + speed: Double, + frequency: CGFloat, + phaseOffset: CGFloat, + gradient: Gradient, + lineWidth: CGFloat + ) { + let phase = CGFloat(time * speed) + phaseOffset + var path = Path() + path.move(to: CGPoint(x: 0, y: midY)) + + let step: CGFloat = 2.0 + for x in stride(from: 0.0, through: width, by: step) { + let normalizedX = x / width + // Envelope: starts at 0, goes to 1 in the middle, decays to 0 at the end + let envelope = sin(normalizedX * .pi) + + let y = midY + sin(x * frequency - phase) * amplitude * envelope + path.addLine(to: CGPoint(x: x, y: y)) + } + + // Glow shadow + c.drawLayer { shadowContext in + shadowContext.addFilter(.blur(radius: 1.2)) + shadowContext.stroke( + path, + with: .linearGradient( + gradient, + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: width, y: 0) + ), + lineWidth: lineWidth * 1.8 + ) + } + + c.stroke( + path, + with: .linearGradient( + gradient, + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: width, y: 0) + ), + lineWidth: lineWidth + ) + } +} + // MARK: - Claude Logo (official sunburst from simple-icons, viewBox 0 0 24 24) private struct ClaudeLogo: View { @@ -2407,6 +3279,7 @@ private struct TerminalBadge: View { "stepfun": "com.stepfun.app", "codex": "com.openai.codex", "opencode": "ai.opencode.desktop", + "wechat": "com.tencent.xinWeChat", ] private static var termIconCache: [String: NSImage] = [:] @@ -2572,6 +3445,7 @@ private let cliIconFiles: [String: String] = [ "kimi": "kimi", "opencode": "opencode", "cline": "cline", + "wechat": "wechat", ] private var cliIconCache: [String: NSImage] = [:] diff --git a/Sources/CodeIsland/OpenCodeView.swift b/Sources/UniIsland/OpenCodeView.swift similarity index 99% rename from Sources/CodeIsland/OpenCodeView.swift rename to Sources/UniIsland/OpenCodeView.swift index eb178596..d1c4af5d 100644 --- a/Sources/CodeIsland/OpenCodeView.swift +++ b/Sources/UniIsland/OpenCodeView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// OpBot — OpenCode mascot, pixel-art dark terminal block with `{ }` face. /// Minimalist geometric style matching OpenCode's monochrome branding. diff --git a/Sources/CodeIsland/PanelWindowController.swift b/Sources/UniIsland/PanelWindowController.swift similarity index 89% rename from Sources/CodeIsland/PanelWindowController.swift rename to Sources/UniIsland/PanelWindowController.swift index 34294c01..c352b01e 100644 --- a/Sources/CodeIsland/PanelWindowController.swift +++ b/Sources/UniIsland/PanelWindowController.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI import os.log -private let log = Logger(subsystem: "com.codeisland", category: "Panel") +private let log = Logger(subsystem: "com.uniisland", category: "Panel") private class KeyablePanel: NSPanel { override var canBecomeKey: Bool { true } @@ -144,6 +144,9 @@ class PanelWindowController: NSObject, NSWindowDelegate { private var lastDisplayChoice = "" private var lastNotchHeightMode = SettingsDefaults.notchHeightMode private var lastCustomNotchHeight = SettingsDefaults.customNotchHeight + private var isSystemSleepingOrLocked = false + private var workspaceObservers: [NSObjectProtocol] = [] + private var distributedObservers: [NSObjectProtocol] = [] init(appState: AppState) { self.appState = appState @@ -249,10 +252,44 @@ class PanelWindowController: NSObject, NSWindowDelegate { } } - // Observe settings changes (display choice, panel height) observeSettingsChanges() configureAutoScreenPolling() + // Sleep/Lock notifications + let wsNC = NSWorkspace.shared.notificationCenter + let distNC = DistributedNotificationCenter.default() + + let sleepHandler: @Sendable (Notification) -> Void = { [weak self] _ in + Task { @MainActor in + self?.isSystemSleepingOrLocked = true + log.info("System sleep or screen lock detected, layout/animations suspended") + } + } + + let wakeHandler: @Sendable (Notification) -> Void = { [weak self] _ in + Task { @MainActor in + guard let self = self else { return } + self.isSystemSleepingOrLocked = false + self.isAnimatingScreenHop = false // Recover from any stuck animation + log.info("System wake or screen unlock detected, restoring notch panel...") + + if let panel = self.panel { + panel.alphaValue = 1 + } + + try? await Task.sleep(nanoseconds: 500_000_000) + self.refreshCurrentScreen(forceRebuild: true) + } + } + + workspaceObservers.append(wsNC.addObserver(forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main, using: sleepHandler)) + workspaceObservers.append(wsNC.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main, using: sleepHandler)) + distributedObservers.append(distNC.addObserver(forName: NSNotification.Name("com.apple.screenIsLocked"), object: nil, queue: .main, using: sleepHandler)) + + workspaceObservers.append(wsNC.addObserver(forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main, using: wakeHandler)) + workspaceObservers.append(wsNC.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main, using: wakeHandler)) + distributedObservers.append(distNC.addObserver(forName: NSNotification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main, using: wakeHandler)) + // Global click monitor: close panel + repost click when clicking outside globalClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in Task { @MainActor in @@ -305,6 +342,7 @@ class PanelWindowController: NSObject, NSWindowDelegate { } private func refreshCurrentScreen(forceRebuild: Bool = false) { + if isSystemSleepingOrLocked { return } if isAnimatingScreenHop { return } let screen = chosenScreen() @@ -546,10 +584,8 @@ class PanelWindowController: NSObject, NSWindowDelegate { return } - if settings.hideWhenNoSession && appState.activeSessionCount == 0 { - panel.orderOut(nil) - return - } + // Always keep the panel visible (idle state shows when no sessions) + // Removed hideWhenNoSession check to maintain idle indicator display if !panel.isVisible { panel.orderFrontRegardless() @@ -601,6 +637,12 @@ class PanelWindowController: NSObject, NSWindowDelegate { for observer in settingsObservers { NotificationCenter.default.removeObserver(observer) } + for observer in workspaceObservers { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + } + for observer in distributedObservers { + DistributedNotificationCenter.default().removeObserver(observer) + } if let monitor = globalClickMonitor { NSEvent.removeMonitor(monitor) } diff --git a/Sources/CodeIsland/PixelCharacterView.swift b/Sources/UniIsland/PixelCharacterView.swift similarity index 99% rename from Sources/CodeIsland/PixelCharacterView.swift rename to Sources/UniIsland/PixelCharacterView.swift index adcc4dcf..e1d1a760 100644 --- a/Sources/CodeIsland/PixelCharacterView.swift +++ b/Sources/UniIsland/PixelCharacterView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// Clawd — Claude mascot, adapted from clawd-on-desk SVG pixel art. /// Renders SVG rects proportionally via Canvas + TimelineView animations. diff --git a/Sources/CodeIsland/ProcessRunner.swift b/Sources/UniIsland/ProcessRunner.swift similarity index 100% rename from Sources/CodeIsland/ProcessRunner.swift rename to Sources/UniIsland/ProcessRunner.swift diff --git a/Sources/CodeIsland/QoderView.swift b/Sources/UniIsland/QoderView.swift similarity index 99% rename from Sources/CodeIsland/QoderView.swift rename to Sources/UniIsland/QoderView.swift index 953c5f4f..80bc2769 100644 --- a/Sources/CodeIsland/QoderView.swift +++ b/Sources/UniIsland/QoderView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// QoderBot — Qoder mascot, pixel-art chat bubble with "Q" face. /// Brand lime green #2ADB5C on dark background. diff --git a/Sources/CodeIsland/QwenView.swift b/Sources/UniIsland/QwenView.swift similarity index 99% rename from Sources/CodeIsland/QwenView.swift rename to Sources/UniIsland/QwenView.swift index 5b34cacf..3f95accb 100644 --- a/Sources/CodeIsland/QwenView.swift +++ b/Sources/UniIsland/QwenView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// QwenBot — Qwen Code mascot, interlocking geometric star. /// Purple-violet gradient (#7C3AED → #6D28D9 → #8B5CF6) from Qwen brand. diff --git a/Sources/CodeIsland/RemoteHost.swift b/Sources/UniIsland/RemoteHost.swift similarity index 97% rename from Sources/CodeIsland/RemoteHost.swift rename to Sources/UniIsland/RemoteHost.swift index 546b0124..5c704da9 100644 --- a/Sources/CodeIsland/RemoteHost.swift +++ b/Sources/UniIsland/RemoteHost.swift @@ -57,7 +57,7 @@ struct RemoteHost: Identifiable, Codable, Equatable, Sendable { /// Legacy shared fallback socket path. The live per-user path is resolved at /// connect time via `RemoteInstaller.prepareRemoteSocketPath` (#193); this value /// is only used when probing the remote UID fails. - var remoteSocketPath: String { "/tmp/codeisland.sock" } + var remoteSocketPath: String { "/tmp/uniisland.sock" } var displayAddress: String { if let port { diff --git a/Sources/CodeIsland/RemoteInstaller.swift b/Sources/UniIsland/RemoteInstaller.swift similarity index 95% rename from Sources/CodeIsland/RemoteInstaller.swift rename to Sources/UniIsland/RemoteInstaller.swift index a5f609ee..696a6ab9 100644 --- a/Sources/CodeIsland/RemoteInstaller.swift +++ b/Sources/UniIsland/RemoteInstaller.swift @@ -45,7 +45,7 @@ enum RemoteInstaller { } /// Probe the remote user's UID and return a per-user socket path so that multiple - /// OS users on a shared host don't collide on a single `/tmp/codeisland.sock` (#193). + /// OS users on a shared host don't collide on a single `/tmp/uniisland.sock` (#193). /// Falls back to the legacy shared path when the probe fails (older / restricted host). static func prepareRemoteSocketPath(host: RemoteHost) async -> String { // `id -u` is a bare external command, so it returns the remote uid identically @@ -54,7 +54,7 @@ enum RemoteInstaller { let probe = await runSSH(host: host, command: "id -u", timeout: 8) let uid = probe.stdout.trimmingCharacters(in: .whitespacesAndNewlines) if probe.ok, !uid.isEmpty, uid.allSatisfy({ $0.isNumber }) { - return "/tmp/codeisland-\(uid).sock" + return "/tmp/uniisland-\(uid).sock" } // Probe failed (old / restricted host) — fall back to the legacy shared path. // StreamLocalBindUnlink=yes on the forward already clears any stale socket, so @@ -64,11 +64,11 @@ enum RemoteInstaller { } private static func remoteHookSource() -> String? { - if let url = Bundle.appModule.url(forResource: "codeisland-remote-hook", withExtension: "py", subdirectory: "Resources"), + if let url = Bundle.appModule.url(forResource: "uniisland-remote-hook", withExtension: "py", subdirectory: "Resources"), let src = try? String(contentsOf: url) { return src } - if let url = Bundle.appModule.url(forResource: "codeisland-remote-hook", withExtension: "py"), + if let url = Bundle.appModule.url(forResource: "uniisland-remote-hook", withExtension: "py"), let src = try? String(contentsOf: url) { return src } @@ -76,11 +76,11 @@ enum RemoteInstaller { } private static func remoteOpencodePluginSource() -> String? { - if let url = Bundle.appModule.url(forResource: "codeisland-opencode-remote", withExtension: "js", subdirectory: "Resources"), + if let url = Bundle.appModule.url(forResource: "uniisland-opencode-remote", withExtension: "js", subdirectory: "Resources"), let src = try? String(contentsOf: url) { return src } - if let url = Bundle.appModule.url(forResource: "codeisland-opencode-remote", withExtension: "js"), + if let url = Bundle.appModule.url(forResource: "uniisland-opencode-remote", withExtension: "js"), let src = try? String(contentsOf: url) { return src } @@ -92,7 +92,7 @@ enum RemoteInstaller { let py = """ import base64, os, pathlib -target = pathlib.Path.home() / ".codeisland" / "codeisland-remote-hook.py" +target = pathlib.Path.home() / ".uniisland" / "uniisland-remote-hook.py" target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(base64.b64decode('''\(encoded)''')) os.chmod(target, 0o755) @@ -107,7 +107,7 @@ print(target) let py = """ import base64, os, pathlib -target = pathlib.Path.home() / ".codeisland" / "codeisland-opencode-remote.js" +target = pathlib.Path.home() / ".uniisland" / "uniisland-opencode-remote.js" target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(base64.b64decode('''\(encoded)''')) os.chmod(target, 0o644) @@ -146,7 +146,7 @@ import os import re home = pathlib.Path.home() -hook_path = home / ".codeisland" / "codeisland-remote-hook.py" +hook_path = home / ".uniisland" / "uniisland-remote-hook.py" host_id = \(hostId) host_name = \(hostName) version = \(version) @@ -230,7 +230,7 @@ def write_opencode_config(path, data): write_json(path, data) def command_for(source): - return f"CODEISLAND_SOCKET_PATH={socket_path} CODEISLAND_REMOTE_HOST_ID={json.dumps(host_id)} CODEISLAND_REMOTE_HOST_NAME={json.dumps(host_name)} CODEISLAND_SOURCE={source} python3 ~/.codeisland/codeisland-remote-hook.py" + return f"UNIISLAND_SOCKET_PATH={socket_path} UNIISLAND_REMOTE_HOST_ID={json.dumps(host_id)} UNIISLAND_REMOTE_HOST_NAME={json.dumps(host_name)} UNIISLAND_SOURCE={source} python3 ~/.uniisland/uniisland-remote-hook.py" def remove_our_hooks(hooks): for event in list(hooks.keys()): @@ -249,7 +249,7 @@ def remove_our_hooks(hooks): commands.append(entry["command"]) if isinstance(entry.get("bash"), str): commands.append(entry["bash"]) - if any("codeisland-remote-hook.py" in c for c in commands): + if any("uniisland-remote-hook.py" in c for c in commands): continue next_entries.append(entry) if next_entries: @@ -681,7 +681,7 @@ def install_opencode(): if not opencode_root.exists() and shutil.which("opencode") is None: return "OpenCode skipped" - plugin_path = home / ".codeisland" / "codeisland-opencode-remote.js" + plugin_path = home / ".uniisland" / "uniisland-opencode-remote.js" if not plugin_path.exists(): return "OpenCode plugin missing" @@ -698,7 +698,7 @@ def install_opencode(): plugins = [] plugins = [ p for p in plugins - if not (isinstance(p, str) and ("vibe-island" in p or "codeisland" in p)) + if not (isinstance(p, str) and ("vibe-island" in p or "uniisland" in p)) ] plugins.append(plugin_ref) data["plugin"] = plugins @@ -709,7 +709,7 @@ def install_opencode(): if legacy_path.exists(): legacy = ensure_jsonc_object(legacy_path) if isinstance(legacy, dict) and isinstance(legacy.get("plugin"), list): - cleaned = [p for p in legacy["plugin"] if not (isinstance(p, str) and ("vibe-island" in p or "codeisland" in p))] + cleaned = [p for p in legacy["plugin"] if not (isinstance(p, str) and ("vibe-island" in p or "uniisland" in p))] if cleaned != legacy["plugin"]: if cleaned: legacy["plugin"] = cleaned @@ -846,15 +846,15 @@ print(" · ".join(parts)) let socketPath = remoteSocketPath ?? host.remoteSocketPath return source .replacingOccurrences( - of: #"const SOCKET_PATH = process.env.CODEISLAND_SOCKET_PATH || "/tmp/codeisland.sock";"#, + of: #"const SOCKET_PATH = process.env.UNIISLAND_SOCKET_PATH || "/tmp/uniisland.sock";"#, with: #"const SOCKET_PATH = \#(jsonStringLiteral(socketPath));"# ) .replacingOccurrences( - of: #"const REMOTE_HOST_ID = process.env.CODEISLAND_REMOTE_HOST_ID || "";"#, + of: #"const REMOTE_HOST_ID = process.env.UNIISLAND_REMOTE_HOST_ID || "";"#, with: #"const REMOTE_HOST_ID = \#(jsonStringLiteral(host.id));"# ) .replacingOccurrences( - of: #"const REMOTE_HOST_NAME = process.env.CODEISLAND_REMOTE_HOST_NAME || "";"#, + of: #"const REMOTE_HOST_NAME = process.env.UNIISLAND_REMOTE_HOST_NAME || "";"#, with: #"const REMOTE_HOST_NAME = \#(jsonStringLiteral(host.name));"# ) } diff --git a/Sources/CodeIsland/RemoteManager.swift b/Sources/UniIsland/RemoteManager.swift similarity index 100% rename from Sources/CodeIsland/RemoteManager.swift rename to Sources/UniIsland/RemoteManager.swift diff --git a/Sources/CodeIsland/Resources/8bit_approval.wav b/Sources/UniIsland/Resources/8bit_approval.wav similarity index 100% rename from Sources/CodeIsland/Resources/8bit_approval.wav rename to Sources/UniIsland/Resources/8bit_approval.wav diff --git a/Sources/CodeIsland/Resources/8bit_boot.wav b/Sources/UniIsland/Resources/8bit_boot.wav similarity index 100% rename from Sources/CodeIsland/Resources/8bit_boot.wav rename to Sources/UniIsland/Resources/8bit_boot.wav diff --git a/Sources/CodeIsland/Resources/8bit_complete.wav b/Sources/UniIsland/Resources/8bit_complete.wav similarity index 100% rename from Sources/CodeIsland/Resources/8bit_complete.wav rename to Sources/UniIsland/Resources/8bit_complete.wav diff --git a/Sources/CodeIsland/Resources/8bit_error.wav b/Sources/UniIsland/Resources/8bit_error.wav similarity index 100% rename from Sources/CodeIsland/Resources/8bit_error.wav rename to Sources/UniIsland/Resources/8bit_error.wav diff --git a/Sources/CodeIsland/Resources/8bit_start.wav b/Sources/UniIsland/Resources/8bit_start.wav similarity index 100% rename from Sources/CodeIsland/Resources/8bit_start.wav rename to Sources/UniIsland/Resources/8bit_start.wav diff --git a/Sources/CodeIsland/Resources/8bit_submit.wav b/Sources/UniIsland/Resources/8bit_submit.wav similarity index 100% rename from Sources/CodeIsland/Resources/8bit_submit.wav rename to Sources/UniIsland/Resources/8bit_submit.wav diff --git a/Sources/UniIsland/Resources/AppIcon.icns b/Sources/UniIsland/Resources/AppIcon.icns new file mode 100644 index 00000000..6abd0d69 Binary files /dev/null and b/Sources/UniIsland/Resources/AppIcon.icns differ diff --git a/Sources/CodeIsland/Resources/Assets.car b/Sources/UniIsland/Resources/Assets.car similarity index 100% rename from Sources/CodeIsland/Resources/Assets.car rename to Sources/UniIsland/Resources/Assets.car diff --git a/Sources/CodeIsland/Resources/cli-icons/antigravity.png b/Sources/UniIsland/Resources/cli-icons/antigravity.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/antigravity.png rename to Sources/UniIsland/Resources/cli-icons/antigravity.png diff --git a/Sources/CodeIsland/Resources/cli-icons/claude.png b/Sources/UniIsland/Resources/cli-icons/claude.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/claude.png rename to Sources/UniIsland/Resources/cli-icons/claude.png diff --git a/Sources/CodeIsland/Resources/cli-icons/cline.png b/Sources/UniIsland/Resources/cli-icons/cline.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/cline.png rename to Sources/UniIsland/Resources/cli-icons/cline.png diff --git a/Sources/CodeIsland/Resources/cli-icons/codebuddy.png b/Sources/UniIsland/Resources/cli-icons/codebuddy.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/codebuddy.png rename to Sources/UniIsland/Resources/cli-icons/codebuddy.png diff --git a/Sources/CodeIsland/Resources/cli-icons/codex.png b/Sources/UniIsland/Resources/cli-icons/codex.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/codex.png rename to Sources/UniIsland/Resources/cli-icons/codex.png diff --git a/Sources/CodeIsland/Resources/cli-icons/copilot.png b/Sources/UniIsland/Resources/cli-icons/copilot.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/copilot.png rename to Sources/UniIsland/Resources/cli-icons/copilot.png diff --git a/Sources/CodeIsland/Resources/cli-icons/cursor.png b/Sources/UniIsland/Resources/cli-icons/cursor.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/cursor.png rename to Sources/UniIsland/Resources/cli-icons/cursor.png diff --git a/Sources/CodeIsland/Resources/cli-icons/factory.png b/Sources/UniIsland/Resources/cli-icons/factory.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/factory.png rename to Sources/UniIsland/Resources/cli-icons/factory.png diff --git a/Sources/CodeIsland/Resources/cli-icons/gemini.png b/Sources/UniIsland/Resources/cli-icons/gemini.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/gemini.png rename to Sources/UniIsland/Resources/cli-icons/gemini.png diff --git a/Sources/CodeIsland/Resources/cli-icons/hermes.png b/Sources/UniIsland/Resources/cli-icons/hermes.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/hermes.png rename to Sources/UniIsland/Resources/cli-icons/hermes.png diff --git a/Sources/CodeIsland/Resources/cli-icons/kimi.png b/Sources/UniIsland/Resources/cli-icons/kimi.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/kimi.png rename to Sources/UniIsland/Resources/cli-icons/kimi.png diff --git a/Sources/CodeIsland/Resources/cli-icons/opencode.png b/Sources/UniIsland/Resources/cli-icons/opencode.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/opencode.png rename to Sources/UniIsland/Resources/cli-icons/opencode.png diff --git a/Sources/CodeIsland/Resources/cli-icons/pi.png b/Sources/UniIsland/Resources/cli-icons/pi.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/pi.png rename to Sources/UniIsland/Resources/cli-icons/pi.png diff --git a/Sources/CodeIsland/Resources/cli-icons/qoder.png b/Sources/UniIsland/Resources/cli-icons/qoder.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/qoder.png rename to Sources/UniIsland/Resources/cli-icons/qoder.png diff --git a/Sources/CodeIsland/Resources/cli-icons/qwen.png b/Sources/UniIsland/Resources/cli-icons/qwen.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/qwen.png rename to Sources/UniIsland/Resources/cli-icons/qwen.png diff --git a/Sources/CodeIsland/Resources/cli-icons/stepfun.png b/Sources/UniIsland/Resources/cli-icons/stepfun.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/stepfun.png rename to Sources/UniIsland/Resources/cli-icons/stepfun.png diff --git a/Sources/CodeIsland/Resources/cli-icons/trae.png b/Sources/UniIsland/Resources/cli-icons/trae.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/trae.png rename to Sources/UniIsland/Resources/cli-icons/trae.png diff --git a/Sources/UniIsland/Resources/cli-icons/wechat.png b/Sources/UniIsland/Resources/cli-icons/wechat.png new file mode 100644 index 00000000..fce5443c Binary files /dev/null and b/Sources/UniIsland/Resources/cli-icons/wechat.png differ diff --git a/Sources/CodeIsland/Resources/cli-icons/workbuddy.png b/Sources/UniIsland/Resources/cli-icons/workbuddy.png similarity index 100% rename from Sources/CodeIsland/Resources/cli-icons/workbuddy.png rename to Sources/UniIsland/Resources/cli-icons/workbuddy.png diff --git a/Sources/CodeIsland/Resources/codeisland-omp.ts b/Sources/UniIsland/Resources/uniisland-omp.ts similarity index 93% rename from Sources/CodeIsland/Resources/codeisland-omp.ts rename to Sources/UniIsland/Resources/uniisland-omp.ts index 948ba6c1..1aa1f4db 100644 --- a/Sources/CodeIsland/Resources/codeisland-omp.ts +++ b/Sources/UniIsland/Resources/uniisland-omp.ts @@ -1,11 +1,11 @@ -// CodeIsland pi extension +// UniIsland pi extension // version: v1 // OMP-compatible install /** - * @fileoverview CodeIsland Integration Extension for Oh My Pi / OMP. + * @fileoverview UniIsland Integration Extension for Oh My Pi / OMP. * - * This is the same socket bridge as codeisland-pi.ts, but imports OMP's + * This is the same socket bridge as uniisland-pi.ts, but imports OMP's * package scope so `omp` can load it from ~/.omp/agent/extensions. */ @@ -18,18 +18,18 @@ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent/extensibility/exten // ── Socket / bridge constants ───────────────────────────────────────────────── -/** Unix socket path CodeIsland listens on (user-scoped). */ +/** Unix socket path UniIsland listens on (user-scoped). */ const userId = getuid?.() ?? 0; -const SOCKET_PATH = `/tmp/codeisland-${userId}.sock`; +const SOCKET_PATH = `/tmp/uniisland-${userId}.sock`; /** * Bridge binary path. Used for blocking permission requests because Node's * half-close (`sock.end()`) causes NWConnection to close before the response * arrives on macOS; the bridge uses POSIX `shutdown(SHUT_WR)` which works. */ -const BRIDGE_PATH = `${homedir()}/.codeisland/codeisland-bridge`; +const BRIDGE_PATH = `${homedir()}/.uniisland/uniisland-bridge`; -/** Environment variable keys forwarded to CodeIsland for terminal detection. */ +/** Environment variable keys forwarded to UniIsland for terminal detection. */ const ENV_KEYS = [ "TERM_PROGRAM", "ITERM_SESSION_ID", @@ -91,8 +91,8 @@ function detectTty(): string | null { // ── Socket communication ────────────────────────────────────────────────────── /** - * Sends a JSON payload to the CodeIsland socket (fire-and-forget). - * Returns `false` silently when CodeIsland is not running. + * Sends a JSON payload to the UniIsland socket (fire-and-forget). + * Returns `false` silently when UniIsland is not running. * * @param payload - Event object to serialise and send. * @returns `true` on successful delivery, `false` otherwise. @@ -117,7 +117,7 @@ function sendToSocket(payload: object): Promise { } /** - * Sends a JSON payload via the bridge binary and waits for CodeIsland's response. + * Sends a JSON payload via the bridge binary and waits for UniIsland's response. * Used exclusively for blocking permission/question requests. * * @param payload - Blocking request object. @@ -161,7 +161,7 @@ function sendAndWaitResponse( // ── Event builders ──────────────────────────────────────────────────────────── /** - * Builds the base fields required on every CodeIsland event payload. + * Builds the base fields required on every UniIsland event payload. * * @param sessionId - Pi session UUID (prefixed with `"pi-"`). * @param cwd - Current working directory. @@ -214,13 +214,13 @@ function extractLastAssistantText( // ── Extension ───────────────────────────────────────────────────────────────── -export default function codeislandExtension(pi: ExtensionAPI) { +export default function uniislandExtension(pi: ExtensionAPI) { /** TTY path detected once at startup. */ const tty = detectTty(); /** * Session IDs for which a blocking PermissionRequest is currently in flight. - * Non-lifecycle events for these sessions are suppressed to prevent CodeIsland's + * Non-lifecycle events for these sessions are suppressed to prevent UniIsland's * "answered externally" heuristic from auto-denying while the card is visible. */ const pendingPermissionSessions = new Set(); @@ -325,7 +325,7 @@ export default function codeislandExtension(pi: ExtensionAPI) { )?.decision as Record | undefined; if (behavior?.behavior === "deny") { - return { block: true, reason: "Blocked by CodeIsland" }; + return { block: true, reason: "Blocked by UniIsland" }; } // Approved — fall through to normal PreToolUse event below. diff --git a/Sources/CodeIsland/Resources/codeisland-opencode-remote.js b/Sources/UniIsland/Resources/uniisland-opencode-remote.js similarity index 94% rename from Sources/CodeIsland/Resources/codeisland-opencode-remote.js rename to Sources/UniIsland/Resources/uniisland-opencode-remote.js index 7d26db48..512fce62 100644 --- a/Sources/CodeIsland/Resources/codeisland-opencode-remote.js +++ b/Sources/UniIsland/Resources/uniisland-opencode-remote.js @@ -1,12 +1,12 @@ -// codeisland-opencode-remote-plugin — auto-generated by CodeIsland +// uniisland-opencode-remote-plugin — auto-generated by UniIsland // version: v2 -// Remote OpenCode event forwarding via ~/.codeisland/codeisland-remote-hook.py +// Remote OpenCode event forwarding via ~/.uniisland/uniisland-remote-hook.py import { execFile } from "child_process"; -const HOOK_PATH = `${process.env.HOME}/.codeisland/codeisland-remote-hook.py`; -const SOCKET_PATH = process.env.CODEISLAND_SOCKET_PATH || "/tmp/codeisland.sock"; -const REMOTE_HOST_ID = process.env.CODEISLAND_REMOTE_HOST_ID || ""; -const REMOTE_HOST_NAME = process.env.CODEISLAND_REMOTE_HOST_NAME || ""; +const HOOK_PATH = `${process.env.HOME}/.uniisland/uniisland-remote-hook.py`; +const SOCKET_PATH = process.env.UNIISLAND_SOCKET_PATH || "/tmp/uniisland.sock"; +const REMOTE_HOST_ID = process.env.UNIISLAND_REMOTE_HOST_ID || ""; +const REMOTE_HOST_NAME = process.env.UNIISLAND_REMOTE_HOST_NAME || ""; function sendRemoteHook(json, timeoutMs = 300000) { return new Promise((resolve) => { @@ -16,10 +16,10 @@ function sendRemoteHook(json, timeoutMs = 300000) { maxBuffer: 1024 * 1024, env: { ...process.env, - CODEISLAND_SOCKET_PATH: SOCKET_PATH, - CODEISLAND_REMOTE_HOST_ID: REMOTE_HOST_ID, - CODEISLAND_REMOTE_HOST_NAME: REMOTE_HOST_NAME, - CODEISLAND_SOURCE: "opencode", + UNIISLAND_SOCKET_PATH: SOCKET_PATH, + UNIISLAND_REMOTE_HOST_ID: REMOTE_HOST_ID, + UNIISLAND_REMOTE_HOST_NAME: REMOTE_HOST_NAME, + UNIISLAND_SOURCE: "opencode", }, }, (error, stdout) => { if (error) { resolve(null); return; } @@ -34,7 +34,7 @@ function sendRemoteHook(json, timeoutMs = 300000) { } export default { - id: "codeisland-remote", + id: "uniisland-remote", server: async ({ client, serverUrl }) => { const pid = process.pid; const serverPort = serverUrl ? parseInt(serverUrl.port) || 4096 : 4096; diff --git a/Sources/CodeIsland/Resources/codeisland-opencode.js b/Sources/UniIsland/Resources/uniisland-opencode.js similarity index 97% rename from Sources/CodeIsland/Resources/codeisland-opencode.js rename to Sources/UniIsland/Resources/uniisland-opencode.js index b0248602..4909257d 100644 --- a/Sources/CodeIsland/Resources/codeisland-opencode.js +++ b/Sources/UniIsland/Resources/uniisland-opencode.js @@ -1,10 +1,10 @@ -// codeisland-opencode-plugin — auto-generated by CodeIsland +// uniisland-opencode-plugin — auto-generated by UniIsland // version: v4 // OpenCode event forwarding + terminal env injection + permission/question handling import { connect } from "net"; import { getuid } from "process"; -const SOCKET = `/tmp/codeisland-${getuid()}.sock`; +const SOCKET = `/tmp/uniisland-${getuid()}.sock`; function sendToSocket(json) { return new Promise((resolve) => { @@ -24,7 +24,7 @@ function sendToSocket(json) { // uses POSIX sockets. Node.js net module's half-close (sock.end()) causes NWConnection // to close immediately on macOS, losing the response. The bridge's shutdown(SHUT_WR) // works correctly with NWConnection. -const BRIDGE_PATH = require("path").join(require("os").homedir(), ".codeisland", "codeisland-bridge"); +const BRIDGE_PATH = require("path").join(require("os").homedir(), ".uniisland", "uniisland-bridge"); function sendAndWaitResponse(json, timeoutMs = 300000) { return new Promise((resolve) => { @@ -43,7 +43,7 @@ function sendAndWaitResponse(json, timeoutMs = 300000) { // v1.3.11+ requires default export with server() entrypoint; v1.4+ requires id field export default { - id: "codeisland", + id: "uniisland", server: async ({ client, serverUrl }) => { const pid = process.pid; const serverPort = serverUrl ? parseInt(serverUrl.port) || 4096 : 4096; @@ -115,7 +115,7 @@ export default { const sessionCwd = new Map(); // Sessions with an active pending permission/question request. - // While pending, suppress non-lifecycle events to prevent CodeIsland's + // While pending, suppress non-lifecycle events to prevent UniIsland's // "answered externally" logic from draining the request via race condition. const pendingRequestSessions = new Set(); @@ -321,7 +321,7 @@ export default { "event": async ({ event }) => { // Reply events (permission.replied, question.replied/rejected) indicate the // request was answered externally (e.g. user replied in terminal). These must - // always flow through so CodeIsland can dismiss the stale approval/question card. + // always flow through so UniIsland can dismiss the stale approval/question card. const isReplyEvent = event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected"; @@ -345,7 +345,7 @@ export default { } // Suppress non-lifecycle events while a permission/question is pending for this // session. Without this, concurrent events (PreToolUse, PostToolUse, Stop) trigger - // CodeIsland's "answered externally" heuristic, which drains the pending request + // UniIsland's "answered externally" heuristic, which drains the pending request // with an auto-deny before the user can click approve. // Reply events are exempted — they genuinely indicate external resolution. if (!isReplyEvent diff --git a/Sources/CodeIsland/Resources/codeisland-pi.ts b/Sources/UniIsland/Resources/uniisland-pi.ts similarity index 89% rename from Sources/CodeIsland/Resources/codeisland-pi.ts rename to Sources/UniIsland/Resources/uniisland-pi.ts index 7da01e6e..0f4971ae 100644 --- a/Sources/CodeIsland/Resources/codeisland-pi.ts +++ b/Sources/UniIsland/Resources/uniisland-pi.ts @@ -1,17 +1,17 @@ -// CodeIsland pi extension +// UniIsland pi extension // version: v1 /** - * @fileoverview CodeIsland Integration Extension. + * @fileoverview UniIsland Integration Extension. * - * Bridges the running pi session to the CodeIsland macOS floating-window app - * (https://github.com/wxtsky/CodeIsland) by forwarding lifecycle events over - * the Unix domain socket CodeIsland listens on. + * Bridges the running pi session to the UniIsland macOS floating-window app + * (https://github.com/wxtsky/UniIsland) by forwarding lifecycle events over + * the Unix domain socket UniIsland listens on. * * Architecture: - * pi (this extension) ──→ /tmp/codeisland-{uid}.sock ──→ CodeIsland.app + * pi (this extension) ──→ /tmp/uniisland-{uid}.sock ──→ UniIsland.app * - * The extension is a socket CLIENT — no server is started. If CodeIsland is not + * The extension is a socket CLIENT — no server is started. If UniIsland is not * running the socket does not exist and all send calls fail silently. * * Event mapping: @@ -26,15 +26,15 @@ * * Permission handling: * Dangerous bash commands (`rm -rf`, `sudo`, `chmod 777`) are intercepted and - * sent as a blocking PermissionRequest via the codeisland-bridge binary. The - * extension waits for CodeIsland's decision and returns allow/block accordingly. - * This replaces the built-in permission-gate.ts when CodeIsland is active. + * sent as a blocking PermissionRequest via the uniisland-bridge binary. The + * extension waits for UniIsland's decision and returns allow/block accordingly. + * This replaces the built-in permission-gate.ts when UniIsland is active. * * Installation: * Drop this file in ~/.pi/agent/extensions/ — it is auto-discovered. * * Requirements: - * - CodeIsland.app running on the same machine + * - UniIsland.app running on the same machine */ import { execFile, execFileSync } from "node:child_process"; @@ -47,18 +47,18 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; // ── Socket / bridge constants ───────────────────────────────────────────────── -/** Unix socket path CodeIsland listens on (user-scoped). */ +/** Unix socket path UniIsland listens on (user-scoped). */ const userId = getuid?.() ?? 0; -const SOCKET_PATH = `/tmp/codeisland-${userId}.sock`; +const SOCKET_PATH = `/tmp/uniisland-${userId}.sock`; /** * Bridge binary path. Used for blocking permission requests because Node's * half-close (`sock.end()`) causes NWConnection to close before the response * arrives on macOS; the bridge uses POSIX `shutdown(SHUT_WR)` which works. */ -const BRIDGE_PATH = `${homedir()}/.codeisland/codeisland-bridge`; +const BRIDGE_PATH = `${homedir()}/.uniisland/uniisland-bridge`; -/** Environment variable keys forwarded to CodeIsland for terminal detection. */ +/** Environment variable keys forwarded to UniIsland for terminal detection. */ const ENV_KEYS = [ "TERM_PROGRAM", "ITERM_SESSION_ID", @@ -120,8 +120,8 @@ function detectTty(): string | null { // ── Socket communication ────────────────────────────────────────────────────── /** - * Sends a JSON payload to the CodeIsland socket (fire-and-forget). - * Returns `false` silently when CodeIsland is not running. + * Sends a JSON payload to the UniIsland socket (fire-and-forget). + * Returns `false` silently when UniIsland is not running. * * @param payload - Event object to serialise and send. * @returns `true` on successful delivery, `false` otherwise. @@ -146,7 +146,7 @@ function sendToSocket(payload: object): Promise { } /** - * Sends a JSON payload via the bridge binary and waits for CodeIsland's response. + * Sends a JSON payload via the bridge binary and waits for UniIsland's response. * Used exclusively for blocking permission/question requests. * * @param payload - Blocking request object. @@ -190,7 +190,7 @@ function sendAndWaitResponse( // ── Event builders ──────────────────────────────────────────────────────────── /** - * Builds the base fields required on every CodeIsland event payload. + * Builds the base fields required on every UniIsland event payload. * * @param sessionId - Pi session UUID (prefixed with `"pi-"`). * @param cwd - Current working directory. @@ -243,13 +243,13 @@ function extractLastAssistantText( // ── Extension ───────────────────────────────────────────────────────────────── -export default function codeislandExtension(pi: ExtensionAPI) { +export default function uniislandExtension(pi: ExtensionAPI) { /** TTY path detected once at startup. */ const tty = detectTty(); /** * Session IDs for which a blocking PermissionRequest is currently in flight. - * Non-lifecycle events for these sessions are suppressed to prevent CodeIsland's + * Non-lifecycle events for these sessions are suppressed to prevent UniIsland's * "answered externally" heuristic from auto-denying while the card is visible. */ const pendingPermissionSessions = new Set(); @@ -354,7 +354,7 @@ export default function codeislandExtension(pi: ExtensionAPI) { )?.decision as Record | undefined; if (behavior?.behavior === "deny") { - return { block: true, reason: "Blocked by CodeIsland" }; + return { block: true, reason: "Blocked by UniIsland" }; } // Approved — fall through to normal PreToolUse event below. diff --git a/Sources/CodeIsland/Resources/codeisland-remote-hook.py b/Sources/UniIsland/Resources/uniisland-remote-hook.py similarity index 94% rename from Sources/CodeIsland/Resources/codeisland-remote-hook.py rename to Sources/UniIsland/Resources/uniisland-remote-hook.py index f7db7182..47725139 100644 --- a/Sources/CodeIsland/Resources/codeisland-remote-hook.py +++ b/Sources/UniIsland/Resources/uniisland-remote-hook.py @@ -6,18 +6,18 @@ import sys VERSION = "0.1.2" -# Per-user socket path (#193): CodeIsland injects CODEISLAND_SOCKET_PATH via the hook +# Per-user socket path (#193): UniIsland injects UNIISLAND_SOCKET_PATH via the hook # command, but fall back to a uid-scoped path so multiple users on a shared host never -# collide on a single /tmp/codeisland.sock. -SOCKET_PATH = os.environ.get("CODEISLAND_SOCKET_PATH") or f"/tmp/codeisland-{os.getuid()}.sock" -REMOTE_HOST_ID = os.environ.get("CODEISLAND_REMOTE_HOST_ID", "") -REMOTE_HOST_NAME = os.environ.get("CODEISLAND_REMOTE_HOST_NAME", "") -SOURCE = os.environ.get("CODEISLAND_SOURCE", "") +# collide on a single /tmp/uniisland.sock. +SOCKET_PATH = os.environ.get("UNIISLAND_SOCKET_PATH") or f"/tmp/uniisland-{os.getuid()}.sock" +REMOTE_HOST_ID = os.environ.get("UNIISLAND_REMOTE_HOST_ID", "") +REMOTE_HOST_NAME = os.environ.get("UNIISLAND_REMOTE_HOST_NAME", "") +SOURCE = os.environ.get("UNIISLAND_SOURCE", "") TIMEOUT_SECONDS = 300 def _normalize_event(name): - """Best-effort normalization matching CodeIslandCore.EventNormalizer.""" + """Best-effort normalization matching UniIslandCore.EventNormalizer.""" if not isinstance(name, str): return "" # Cursor (camelCase) diff --git a/Sources/CodeIsland/SSHForwarder.swift b/Sources/UniIsland/SSHForwarder.swift similarity index 100% rename from Sources/CodeIsland/SSHForwarder.swift rename to Sources/UniIsland/SSHForwarder.swift diff --git a/Sources/CodeIsland/ScreenDetector.swift b/Sources/UniIsland/ScreenDetector.swift similarity index 99% rename from Sources/CodeIsland/ScreenDetector.swift rename to Sources/UniIsland/ScreenDetector.swift index 2600be18..559f5b81 100644 --- a/Sources/CodeIsland/ScreenDetector.swift +++ b/Sources/UniIsland/ScreenDetector.swift @@ -11,7 +11,7 @@ struct ScreenDetector { /// User scaling is applied in NotchPanelView.effectiveNotchW (reactive via @AppStorage). private static func fakeNotchWidth(for screen: NSScreen) -> CGFloat { let screenW = screen.frame.width - return min(max(screenW * 0.14, 160), 240) + return min(max(screenW * 0.14 - 80, 80), 160) } static func autoPreferredIndex(candidates: [Candidate], activeWindowBounds: CGRect?) -> Int? { diff --git a/Sources/CodeIsland/SessionPersistence.swift b/Sources/UniIsland/SessionPersistence.swift similarity index 98% rename from Sources/CodeIsland/SessionPersistence.swift rename to Sources/UniIsland/SessionPersistence.swift index 3f26b7dd..075083b7 100644 --- a/Sources/CodeIsland/SessionPersistence.swift +++ b/Sources/UniIsland/SessionPersistence.swift @@ -1,5 +1,5 @@ import Foundation -import CodeIslandCore +import UniIslandCore struct PersistedSession: Codable { let sessionId: String @@ -34,7 +34,7 @@ struct PersistedSession: Codable { } enum SessionPersistence { - private static let dirPath = FileManager.default.homeDirectoryForCurrentUser.path + "/.codeisland" + private static let dirPath = FileManager.default.homeDirectoryForCurrentUser.path + "/.uniisland" private static let filePath = dirPath + "/sessions.json" static func save(_ sessions: [String: SessionSnapshot]) { diff --git a/Sources/CodeIsland/SessionTitleStore.swift b/Sources/UniIsland/SessionTitleStore.swift similarity index 99% rename from Sources/CodeIsland/SessionTitleStore.swift rename to Sources/UniIsland/SessionTitleStore.swift index 20141243..1c26f270 100644 --- a/Sources/CodeIsland/SessionTitleStore.swift +++ b/Sources/UniIsland/SessionTitleStore.swift @@ -1,5 +1,5 @@ import Foundation -import CodeIslandCore +import UniIslandCore struct ResolvedSessionTitle: Sendable, Equatable { let title: String diff --git a/Sources/CodeIsland/Settings.swift b/Sources/UniIsland/Settings.swift similarity index 92% rename from Sources/CodeIsland/Settings.swift rename to Sources/UniIsland/Settings.swift index 301aa569..75f64b9e 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/UniIsland/Settings.swift @@ -80,7 +80,7 @@ enum SettingsKey { // Tool status display static let showToolStatus = "showToolStatus" // true = detailed, false = simple - // Island collapsed width scale (percentage: 50–150, default 100) + // Island collapsed width scale (percentage: 50-150, default 100) static let collapsedWidthScale = "collapsedWidthScale" // Default mascot source when no sessions exist (falls back to this instead of always "claude") @@ -104,6 +104,15 @@ enum SettingsKey { static let webhookEnabled = "webhookEnabled" static let webhookURL = "webhookURL" static let webhookEventFilter = "webhookEventFilter" // comma-separated allow-list; empty = forward all + + // Widgets + static let pomodoroEnabled = "pomodoroEnabled" + static let pomodoroDurationMinutes = "pomodoroDurationMinutes" + static let systemMonitorEnabled = "systemMonitorEnabled" + static let systemMonitorIntervalSeconds = "systemMonitorIntervalSeconds" + static let mediaControllerEnabled = "mediaControllerEnabled" + static let batteryMonitorEnabled = "batteryMonitorEnabled" + static let calendarMonitorEnabled = "calendarMonitorEnabled" } struct SettingsDefaults { @@ -170,6 +179,21 @@ struct SettingsDefaults { static let webhookEnabled = false static let webhookURL = "" static let webhookEventFilter = "" + + // Widgets + static let pomodoroEnabled = false + static let pomodoroDurationMinutes = 25.0 + static let systemMonitorEnabled = false + static let systemMonitorIntervalSeconds = 2.0 + static let mediaControllerEnabled = true + static let batteryMonitorEnabled = false + static let calendarMonitorEnabled = false +} + +enum NotchWidthScale { + static let min = 50 + static let max = 150 + static let step = 1 } @MainActor @@ -226,6 +250,13 @@ class SettingsManager { SettingsKey.webhookEnabled: SettingsDefaults.webhookEnabled, SettingsKey.webhookURL: SettingsDefaults.webhookURL, SettingsKey.webhookEventFilter: SettingsDefaults.webhookEventFilter, + SettingsKey.pomodoroEnabled: SettingsDefaults.pomodoroEnabled, + SettingsKey.pomodoroDurationMinutes: SettingsDefaults.pomodoroDurationMinutes, + SettingsKey.systemMonitorEnabled: SettingsDefaults.systemMonitorEnabled, + SettingsKey.systemMonitorIntervalSeconds: SettingsDefaults.systemMonitorIntervalSeconds, + SettingsKey.mediaControllerEnabled: SettingsDefaults.mediaControllerEnabled, + SettingsKey.batteryMonitorEnabled: SettingsDefaults.batteryMonitorEnabled, + SettingsKey.calendarMonitorEnabled: SettingsDefaults.calendarMonitorEnabled, ]) } diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/UniIsland/SettingsView.swift similarity index 98% rename from Sources/CodeIsland/SettingsView.swift rename to Sources/UniIsland/SettingsView.swift index 42175289..8f91ffed 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/UniIsland/SettingsView.swift @@ -1,7 +1,7 @@ import SwiftUI import AppKit import UniformTypeIdentifiers -import CodeIslandCore +import UniIslandCore // MARK: - Navigation Model @@ -9,6 +9,7 @@ enum SettingsPage: String, Identifiable, Hashable { case general case behavior case appearance + case widgets case mascots case sound case shortcuts @@ -24,6 +25,7 @@ enum SettingsPage: String, Identifiable, Hashable { case .general: return "gearshape.fill" case .behavior: return "slider.horizontal.3" case .appearance: return "paintbrush.fill" + case .widgets: return "square.grid.2x2.fill" case .mascots: return "person.2.fill" case .sound: return "speaker.wave.2.fill" case .shortcuts: return "command.circle.fill" @@ -39,6 +41,7 @@ enum SettingsPage: String, Identifiable, Hashable { case .general: return .gray case .behavior: return .orange case .appearance: return .blue + case .widgets: return .purple case .mascots: return .pink case .sound: return .green case .shortcuts: return .indigo @@ -56,8 +59,8 @@ private struct SidebarGroup: Hashable { } private let sidebarGroups: [SidebarGroup] = [ - SidebarGroup(title: nil, pages: [.general, .behavior, .appearance, .mascots, .sound, .shortcuts]), - SidebarGroup(title: "CodeIsland", pages: [.remote, .hooks, .buddy, .about]), + SidebarGroup(title: nil, pages: [.general, .behavior, .appearance, .widgets, .mascots, .sound, .shortcuts]), + SidebarGroup(title: "UniIsland", pages: [.remote, .hooks, .buddy, .about]), ] // MARK: - Main View @@ -91,6 +94,7 @@ struct SettingsView: View { case .general: GeneralPage() case .behavior: BehaviorPage(appState: appState) case .appearance: AppearancePage() + case .widgets: WidgetsPage() case .mascots: MascotsPage() case .sound: SoundPage() case .shortcuts: ShortcutsPage() @@ -802,7 +806,8 @@ private struct AppearancePage: View { AppearancePreview( fontSize: contentFontSize, lineLimit: aiMessageLines, - showDetails: showAgentDetails + showDetails: showAgentDetails, + collapsedWidthScale: collapsedWidthScale ) } @@ -828,7 +833,7 @@ private struct AppearancePage: View { Slider(value: Binding( get: { Double(collapsedWidthScale) }, set: { collapsedWidthScale = Int($0) } - ), in: 50...150, step: 10) + ), in: Double(NotchWidthScale.min)...Double(NotchWidthScale.max), step: Double(NotchWidthScale.step)) Text(l10n["collapsed_width_scale_desc"]) .font(.caption) .foregroundStyle(.secondary) @@ -883,10 +888,16 @@ private struct AppearancePreview: View { let fontSize: Int let lineLimit: Int let showDetails: Bool + let collapsedWidthScale: Int private var fs: CGFloat { CGFloat(fontSize) } private let green = Color(red: 0.3, green: 0.85, blue: 0.4) private let aiColor = Color(red: 0.85, green: 0.47, blue: 0.34) + private var previewPanelWidth: CGFloat { + let clampedScale = max(NotchWidthScale.min, min(collapsedWidthScale, NotchWidthScale.max)) + let scaledWidth = 720 * CGFloat(clampedScale) / 100.0 + return max(360, min(scaledWidth, 900)) + } var body: some View { HStack(alignment: .center, spacing: 8) { @@ -957,13 +968,16 @@ private struct AppearancePreview: View { } .padding(.horizontal, 16) .padding(.vertical, 12) + .frame(maxWidth: previewPanelWidth, alignment: .center) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color(white: 0.05)) ) + .frame(maxWidth: .infinity, alignment: .center) .animation(.easeInOut(duration: 0.25), value: fontSize) .animation(.easeInOut(duration: 0.25), value: lineLimit) .animation(.easeInOut(duration: 0.25), value: showDetails) + .animation(.easeInOut(duration: 0.18), value: collapsedWidthScale) } } @@ -1531,7 +1545,7 @@ private struct AboutPage: View { AppLogoView(size: 100) VStack(spacing: 6) { - Text("CodeIsland") + Text("UniIsland") .font(.system(size: 26, weight: .bold)) Text("Version \(AppVersion.current)") .font(.system(size: 13)) @@ -1548,8 +1562,8 @@ private struct AboutPage: View { } HStack(spacing: 12) { - aboutLink("GitHub", icon: "chevron.left.forwardslash.chevron.right", url: "https://github.com/wxtsky/CodeIsland") - aboutLink("Issues", icon: "ladybug", url: "https://github.com/wxtsky/CodeIsland/issues") + aboutLink("GitHub", icon: "chevron.left.forwardslash.chevron.right", url: "https://github.com/wxtsky/UniIsland") + aboutLink("Issues", icon: "ladybug", url: "https://github.com/wxtsky/UniIsland/issues") } // In-app update section @@ -2251,3 +2265,17 @@ private struct ShortcutRow: View { .contentShape(Rectangle()) } } + +// MARK: - Widgets Page +private struct WidgetsPage: View { + @AppStorage(SettingsKey.mediaControllerEnabled) private var mediaControllerEnabled = SettingsDefaults.mediaControllerEnabled + + var body: some View { + Form { + Section("系统媒体控制 (Media Controls)") { + Toggle("显示播放媒体 Now Playing Monitor", isOn: $mediaControllerEnabled) + } + } + .formStyle(.grouped) + } +} diff --git a/Sources/CodeIsland/SettingsWindowController.swift b/Sources/UniIsland/SettingsWindowController.swift similarity index 100% rename from Sources/CodeIsland/SettingsWindowController.swift rename to Sources/UniIsland/SettingsWindowController.swift diff --git a/Sources/CodeIsland/SoundManager.swift b/Sources/UniIsland/SoundManager.swift similarity index 98% rename from Sources/CodeIsland/SoundManager.swift rename to Sources/UniIsland/SoundManager.swift index 6a599496..127192bf 100644 --- a/Sources/CodeIsland/SoundManager.swift +++ b/Sources/UniIsland/SoundManager.swift @@ -84,7 +84,7 @@ class SoundManager { /// Load a WAV from the SPM resource bundle private func loadSound(_ name: String) -> NSSound? { // SPM generates Bundle.appModule for resource bundles - // Resources are inside CodeIsland_CodeIsland.bundle/Resources/ + // Resources are inside UniIsland_UniIsland.bundle/Resources/ if let url = Bundle.appModule.url(forResource: name, withExtension: "wav", subdirectory: "Resources") { return NSSound(contentsOf: url, byReference: false) } diff --git a/Sources/CodeIsland/StatusItemController.swift b/Sources/UniIsland/StatusItemController.swift similarity index 98% rename from Sources/CodeIsland/StatusItemController.swift rename to Sources/UniIsland/StatusItemController.swift index afbd68bf..79059d40 100644 --- a/Sources/CodeIsland/StatusItemController.swift +++ b/Sources/UniIsland/StatusItemController.swift @@ -39,7 +39,7 @@ final class StatusItemController: NSObject { icon.size = NSSize(width: 18, height: 18) button.image = icon button.imageScaling = .scaleProportionallyDown - button.toolTip = "CodeIsland" + button.toolTip = "UniIsland" } item.menu = menu statusItem = item diff --git a/Sources/CodeIsland/StepFunView.swift b/Sources/UniIsland/StepFunView.swift similarity index 99% rename from Sources/CodeIsland/StepFunView.swift rename to Sources/UniIsland/StepFunView.swift index 5cc116f1..d5928f59 100644 --- a/Sources/CodeIsland/StepFunView.swift +++ b/Sources/UniIsland/StepFunView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// StepFunBot — StepFun mascot, pixel-block staircase character. /// Dark teal #0D9488 with blocky pixel aesthetic matching the step-pattern logo. diff --git a/Sources/UniIsland/SystemStatsHelper.swift b/Sources/UniIsland/SystemStatsHelper.swift new file mode 100644 index 00000000..f2cc7014 --- /dev/null +++ b/Sources/UniIsland/SystemStatsHelper.swift @@ -0,0 +1,161 @@ +import Foundation +import MachO +import Darwin + +/// Helper class to monitor macOS system statistics with zero lag and minimal CPU overhead. +class SystemStatsHelper { + static let shared = SystemStatsHelper() + + private let cpuMonitor = CPUUsageMonitor() + private let netMonitor = NetworkSpeedMonitor() + + private init() {} + + func getCPUUsage() -> Double { + return cpuMonitor.getUsage() + } + + func getMemoryUsage() -> Double { + var stats = vm_statistics64() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &stats) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard result == KERN_SUCCESS else { return 0.0 } + + let pageSize = vm_kernel_page_size + let active = Double(stats.active_count) * Double(pageSize) + let wire = Double(stats.wire_count) * Double(pageSize) + let compressed = Double(stats.compressor_page_count) * Double(pageSize) + let inactive = Double(stats.inactive_count) * Double(pageSize) + + let used = active + wire + compressed + let total = used + inactive + Double(stats.free_count) * Double(pageSize) + + guard total > 0 else { return 0.0 } + return (used / total) * 100.0 + } + + func getNetworkSpeeds() -> (uploadSpeed: Double, downloadSpeed: Double) { + return netMonitor.getSpeeds() + } +} + +// MARK: - CPU Usage Monitor +private class CPUUsageMonitor { + private var prevCpuInfo: processor_info_array_t? + private var numCpuInfo: mach_msg_type_number_t = 0 + private var numCPUs: uint32 = 0 + private let lock = NSLock() + + init() { + var size = MemoryLayout.size + var mib = [CTL_HW, HW_NCPU] + sysctl(&mib, 2, &numCPUs, &size, nil, 0) + } + + func getUsage() -> Double { + var numCPUsU: mach_msg_type_number_t = 0 + var cpuInfo: processor_info_array_t? + var numCpuInfoU: mach_msg_type_number_t = 0 + + let result = host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &numCPUsU, &cpuInfo, &numCpuInfoU) + guard result == KERN_SUCCESS, let cpuInfo = cpuInfo else { + return 0.0 + } + + lock.lock() + defer { lock.unlock() } + + var totalUsage: Double = 0.0 + if let prevInfo = prevCpuInfo { + for i in 0.. 0 { + totalUsage += Double(inUse) / Double(total) + } + } + + // Deallocate previous info + let prevSize = MemoryLayout.size * Int(numCpuInfo) + vm_deallocate(mach_task_self_, vm_address_t(bitPattern: Int(bitPattern: prevInfo)), vm_size_t(prevSize)) + } + + prevCpuInfo = cpuInfo + numCpuInfo = numCpuInfoU + + let finalUsage = (totalUsage / Double(numCPUs)) * 100.0 + return min(100.0, max(0.0, finalUsage)) + } + + deinit { + if let prevInfo = prevCpuInfo { + let prevSize = MemoryLayout.size * Int(numCpuInfo) + vm_deallocate(mach_task_self_, vm_address_t(bitPattern: Int(bitPattern: prevInfo)), vm_size_t(prevSize)) + } + } +} + +// MARK: - Network Speed Monitor +private class NetworkSpeedMonitor { + private var prevBytesIn: UInt64 = 0 + private var prevBytesOut: UInt64 = 0 + private var lastTime: Date = Date() + + func getSpeeds() -> (uploadSpeed: Double, downloadSpeed: Double) { + var bytesIn: UInt64 = 0 + var bytesOut: UInt64 = 0 + + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else { return (0, 0) } + + for ptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + if (flags & IFF_UP) == IFF_UP { + let name = String(cString: ptr.pointee.ifa_name) + // Filter main interface prefixes en (wi-fi/ethernet) and ap (access points) + if name.hasPrefix("en") || name.hasPrefix("ap") { + if ptr.pointee.ifa_addr.pointee.sa_family == UInt8(AF_LINK) { + if let ifData = ptr.pointee.ifa_data { + let bound = ifData.assumingMemoryBound(to: if_data.self) + bytesIn += UInt64(bound.pointee.ifi_ibytes) + bytesOut += UInt64(bound.pointee.ifi_obytes) + } + } + } + } + } + freeifaddrs(ifaddr) + + let now = Date() + let elapsed = now.timeIntervalSince(lastTime) + lastTime = now + + guard elapsed > 0.05 else { return (0, 0) } + + // Return 0 if it's the first sample + if prevBytesIn == 0 && prevBytesOut == 0 { + prevBytesIn = bytesIn + prevBytesOut = bytesOut + return (0, 0) + } + + let upSpeed = Double(bytesOut > prevBytesOut ? bytesOut - prevBytesOut : 0) / elapsed + let downSpeed = Double(bytesIn > prevBytesIn ? bytesIn - prevBytesIn : 0) / elapsed + + prevBytesIn = bytesIn + prevBytesOut = bytesOut + + return (upSpeed, downSpeed) + } +} diff --git a/Sources/CodeIsland/TerminalActivator.swift b/Sources/UniIsland/TerminalActivator.swift similarity index 90% rename from Sources/CodeIsland/TerminalActivator.swift rename to Sources/UniIsland/TerminalActivator.swift index 4d2f3a35..4b38c8ac 100644 --- a/Sources/CodeIsland/TerminalActivator.swift +++ b/Sources/UniIsland/TerminalActivator.swift @@ -1,6 +1,6 @@ import AppKit import ApplicationServices -import CodeIslandCore +import UniIslandCore /// Activates the terminal window/tab running a specific Claude Code session. /// Supports tab-level switching for: Ghostty, iTerm2, Terminal.app, WezTerm, kitty. @@ -50,6 +50,7 @@ struct TerminalActivator { "stepfun": "com.stepfun.app", "opencode": "ai.opencode.desktop", "workbuddy": "com.workbuddy.workbuddy", + "wechat": "com.tencent.xinWeChat", ] /// Bundle IDs of apps that have both APP and CLI modes. @@ -66,11 +67,26 @@ struct TerminalActivator { "com.stepfun.app": "StepFun", "ai.opencode.desktop": "OpenCode", "com.workbuddy.workbuddy": "WorkBuddy", + "com.tencent.xinWeChat": "WeChat", ] + static func shouldUseDirectNativeAppActivation(source: String, bundleId: String?, cwd: String?) -> Bool { + guard let bundleId, nativeAppBundles[bundleId] != nil else { return false } + if source == "wechat" { return true } + return cwd?.isEmpty ?? true + } + static func activate(session: SessionSnapshot, sessionId: String? = nil) { guard !session.isRemote else { return } + if session.source == "drops" { + if let cwd = session.cwd { + let dropsURL = URL(fileURLWithPath: cwd) + NSWorkspace.shared.open(dropsURL) + } + return + } + // Native app by bundle ID (e.g. Codex APP vs Codex CLI). These are IDE-style // apps (Cursor, Trae, Qoder, Factory, …) that can hold several workspace // windows at once, so match the one whose title contains the session's @@ -79,7 +95,11 @@ struct TerminalActivator { // no cwd or no matching window, so this never regresses single-window apps. if let bundleId = session.termBundleId, nativeAppBundles[bundleId] != nil { - activateIDEWindow(bundleId: bundleId, cwd: session.cwd) + if shouldUseDirectNativeAppActivation(source: session.source, bundleId: bundleId, cwd: session.cwd) { + activateByBundleId(bundleId, restoreMinimizedWindow: true) + } else { + activateIDEWindow(bundleId: bundleId, cwd: session.cwd) + } return } @@ -110,7 +130,7 @@ struct TerminalActivator { if session.termBundleId == nil, let nativeBundleId = sourceToNativeAppBundleId[session.source], NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == nativeBundleId }) { - activateByBundleId(nativeBundleId) + activateByBundleId(nativeBundleId, restoreMinimizedWindow: session.source == "wechat") return } @@ -976,7 +996,26 @@ struct TerminalActivator { end try end repeat if bestWindow is not missing value then + -- AXRaise alone does NOT restore a window minimized to the Dock; clear + -- AXMinimized first so clicking the island reopens it (#1, also Claude/WeChat). + try + if value of attribute "AXMinimized" of bestWindow is true then + set value of attribute "AXMinimized" of bestWindow to false + end if + end try perform action "AXRaise" of bestWindow + else + -- No title match (e.g. WeChat, whose window title isn't the folder name): + -- restore the first minimized window so a minimized app still reopens. + repeat with w in windows + try + if value of attribute "AXMinimized" of w is true then + set value of attribute "AXMinimized" of w to false + perform action "AXRaise" of w + exit repeat + end if + end try + end repeat end if end tell end tell @@ -1021,17 +1060,118 @@ struct TerminalActivator { // MARK: - Activate by bundle ID - private static func activateByBundleId(_ bundleId: String) { + private static func activateByBundleId(_ bundleId: String, restoreMinimizedWindow: Bool = false) { + var restoredWindow = false if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleId }) { if app.isHidden { app.unhide() } - app.activate() + if restoreMinimizedWindow { + restoredWindow = restoreFirstWindowViaAccessibility(for: app) + } + _ = app.activate(options: [.activateAllWindows]) + if restoreMinimizedWindow && !restoredWindow { + restoredWindow = restoreFirstWindowViaAccessibility(for: app) + } } // Also use openApplication for reliable Space switching (Electron apps like VSCode) if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) { - NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: url, configuration: configuration) + } + if restoreMinimizedWindow && !restoredWindow { + restoredWindow = restoreFirstWindowViaAccessibility(bundleId: bundleId) + } + if restoreMinimizedWindow && !restoredWindow { + restoreFirstWindowViaSystemEvents(bundleId: bundleId) + } + } + + @discardableResult + private static func restoreFirstWindowViaAccessibility(bundleId: String) -> Bool { + guard let app = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == bundleId + }) else { + return false + } + return restoreFirstWindowViaAccessibility(for: app) + } + + @discardableResult + private static func restoreFirstWindowViaAccessibility(for app: NSRunningApplication) -> Bool { + let appElement = AXUIElementCreateApplication(app.processIdentifier) + var windowsValue: CFTypeRef? + let result = AXUIElementCopyAttributeValue( + appElement, + kAXWindowsAttribute as CFString, + &windowsValue + ) + guard result == .success, + let windows = windowsValue as? [AXUIElement], + !windows.isEmpty + else { + return false + } + + var firstWindow: AXUIElement? + for window in windows { + if firstWindow == nil { + firstWindow = window + } + + var minimizedValue: CFTypeRef? + let minimizedResult = AXUIElementCopyAttributeValue( + window, + kAXMinimizedAttribute as CFString, + &minimizedValue + ) + if minimizedResult == .success, minimizedValue as? Bool == true { + _ = AXUIElementSetAttributeValue( + window, + kAXMinimizedAttribute as CFString, + false as CFBoolean + ) + _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString) + return true + } } + + if let firstWindow { + _ = AXUIElementPerformAction(firstWindow, kAXRaiseAction as CFString) + return true + } + return false + } + + private static func restoreFirstWindowViaSystemEvents(bundleId: String) { + let escapedBundleId = escapeAppleScript(bundleId) + let script = """ + tell application "System Events" + set matchingProcesses to (processes whose bundle identifier is "\(escapedBundleId)") + if (count of matchingProcesses) > 0 then + tell item 1 of matchingProcesses + set frontmost to true + repeat with w in windows + try + if value of attribute "AXMinimized" of w is true then + set value of attribute "AXMinimized" of w to false + perform action "AXRaise" of w + return + end if + end try + end repeat + repeat with w in windows + try + perform action "AXRaise" of w + return + end try + end repeat + end tell + end if + end tell + """ + runAppleScript(script) } /// Bring an app forward without calling `NSRunningApplication.activate()`. diff --git a/Sources/CodeIsland/TerminalVisibilityDetector.swift b/Sources/UniIsland/TerminalVisibilityDetector.swift similarity index 99% rename from Sources/CodeIsland/TerminalVisibilityDetector.swift rename to Sources/UniIsland/TerminalVisibilityDetector.swift index 3254c68f..de7692d7 100644 --- a/Sources/CodeIsland/TerminalVisibilityDetector.swift +++ b/Sources/UniIsland/TerminalVisibilityDetector.swift @@ -1,5 +1,5 @@ import AppKit -import CodeIslandCore +import UniIslandCore /// Detects whether a session's terminal tab/pane is currently the active (visible) one. /// Used by smart-suppress to avoid notifying when the user is already looking at the session. diff --git a/Sources/CodeIsland/TraeView.swift b/Sources/UniIsland/TraeView.swift similarity index 99% rename from Sources/CodeIsland/TraeView.swift rename to Sources/UniIsland/TraeView.swift index 79cbdbfa..175f50e9 100644 --- a/Sources/CodeIsland/TraeView.swift +++ b/Sources/UniIsland/TraeView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// TraeBot — Trae mascot, rounded terminal-screen character. /// Bright green (#22C55E) on dark, resembling a glowing terminal. diff --git a/Sources/CodeIsland/CodeIslandApp.swift b/Sources/UniIsland/UniIslandApp.swift similarity index 89% rename from Sources/CodeIsland/CodeIslandApp.swift rename to Sources/UniIsland/UniIslandApp.swift index 73718e25..d280b4eb 100644 --- a/Sources/CodeIsland/CodeIslandApp.swift +++ b/Sources/UniIsland/UniIslandApp.swift @@ -1,7 +1,7 @@ import SwiftUI @main -struct CodeIslandApp: App { +struct UniIslandApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @ObservedObject private var l10n = L10n.shared diff --git a/Sources/CodeIsland/UpdateChecker.swift b/Sources/UniIsland/UpdateChecker.swift similarity index 97% rename from Sources/CodeIsland/UpdateChecker.swift rename to Sources/UniIsland/UpdateChecker.swift index 6741f2c6..f0430d91 100644 --- a/Sources/CodeIsland/UpdateChecker.swift +++ b/Sources/UniIsland/UpdateChecker.swift @@ -17,7 +17,7 @@ enum UpdateState: Equatable { @MainActor final class UpdateChecker: NSObject, ObservableObject { static let shared = UpdateChecker() - private static let log = Logger(subsystem: "com.codeisland", category: "UpdateChecker") + private static let log = Logger(subsystem: "com.uniisland", category: "UpdateChecker") @Published private(set) var state: UpdateState = .idle diff --git a/Sources/UniIsland/WeChatView.swift b/Sources/UniIsland/WeChatView.swift new file mode 100644 index 00000000..25ae6811 --- /dev/null +++ b/Sources/UniIsland/WeChatView.swift @@ -0,0 +1,290 @@ +import SwiftUI +import UniIslandCore + +/// WeChatView — Premium 8-bit pixel-art WeChat double-bubble mascot. +/// Renders two cute overlapping speech bubbles (one WeChat Green, one Light-Gray) +/// with animated blinking eyes, breathing idle states, typing dots, and startled jumps. +struct WeChatView: View { + let status: AgentStatus + var size: CGFloat = 27 + @State private var alive = false + @Environment(\.mascotSpeed) private var speed + + // Brand Colors + private static let greenLt = Color(red: 0.20, green: 0.82, blue: 0.20) // #33D133 + private static let greenC = Color(red: 0.10, green: 0.68, blue: 0.10) // #1AAD1A (WeChat Green) + private static let greenDk = Color(red: 0.05, green: 0.50, blue: 0.05) // #0D800D + private static let whiteLt = Color(red: 0.98, green: 0.98, blue: 0.98) // #FAFAFA + private static let whiteC = Color(red: 0.92, green: 0.92, blue: 0.92) // #ECECEC (WeChat Light Gray) + private static let whiteDk = Color(red: 0.78, green: 0.78, blue: 0.78) // #C7C7C7 + private static let eyeC = Color.black + private static let alertC = Color(red: 1.0, green: 0.24, blue: 0.0) + + var body: some View { + ZStack { + switch status { + case .idle: sleepScene + case .processing, .running: workScene + case .waitingApproval, .waitingQuestion: alertScene + } + } + .frame(width: size, height: size) + .clipped() + .onAppear { alive = true } + .onChange(of: status) { + alive = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { alive = true } + } + } + + private struct V { + let ox: CGFloat, oy: CGFloat, s: CGFloat, y0: CGFloat + init(_ sz: CGSize, svgW: CGFloat = 16, svgH: CGFloat = 14, svgY0: CGFloat = 4) { + s = min(sz.width / svgW, sz.height / svgH) + ox = (sz.width - svgW * s) / 2 + oy = (sz.height - svgH * s) / 2 + y0 = svgY0 + } + func r(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat, dy: CGFloat = 0) -> CGRect { + CGRect(x: ox + x * s, y: oy + (y - y0 + dy) * s, width: w * s, height: h * s) + } + func path(_ points: [(CGFloat, CGFloat)], dy: CGFloat = 0) -> Path { + var p = Path() + guard let first = points.first else { return p } + p.move(to: CGPoint(x: ox + first.0 * s, y: oy + (first.1 - y0 + dy) * s)) + for i in 1.. CGFloat { + guard let first = keyframes.first else { return 0 } + if pct <= first.0 { return first.1 } + for i in 1.. 0 { + let dotSize: CGFloat = 0.5 + let dotY: CGFloat = 7.0 + + // Draw 3 typing dots inside the green bubble (flash them in sequence) + for i in 0..<3 { + let dotX = 4.3 + CGFloat(i) * 1.5 + let opacity = (typingPhase == i + 1) ? 1.0 : 0.3 + c.fill(Path(v.r(dotX, dotY, dotSize, dotSize, dy: dy)), with: .color(Self.eyeC.opacity(opacity))) + } + } + } + + private func drawShadow(_ c: GraphicsContext, v: V, width: CGFloat = 11, opacity: Double = 0.25) { + c.fill(Path(v.r(8.0 - width / 2, 15.5, width, 1.0)), + with: .color(.black.opacity(opacity))) + } + + // ━━━━━━ SLEEP (Idle State) ━━━━━━ + private var sleepScene: some View { + ZStack { + TimelineView(.periodic(from: .now, by: 0.06)) { ctx in + sleepCanvas(t: ctx.date.timeIntervalSinceReferenceDate * speed) + } + TimelineView(.periodic(from: .now, by: 0.05)) { ctx in + floatingZs(t: ctx.date.timeIntervalSinceReferenceDate * speed) + } + } + } + + private func floatingZs(t: Double) -> some View { + ZStack { + ForEach(0..<3, id: \.self) { i in + let ci = Double(i) + let cycle = 2.8 + ci * 0.3 + let delay = ci * 0.9 + let phase = max(0, ((t - delay).truncatingRemainder(dividingBy: cycle)) / cycle) + let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) + let baseOp = 0.65 - ci * 0.1 + let opacity = phase < 0.8 ? baseOp : (1.0 - phase) * 3.5 * baseOp + let xOff = size * CGFloat(0.16 + ci * 0.08 + sin(phase * .pi * 2) * 0.03) + let yOff = -size * CGFloat(0.12 + phase * 0.38) + Text("z") + .font(.system(size: fontSize, weight: .black, design: .monospaced)) + .foregroundStyle(.white.opacity(opacity)) + .offset(x: xOff, y: yOff) + } + } + } + + private func sleepCanvas(t: Double) -> some View { + let phase = t.truncatingRemainder(dividingBy: 4.0) / 4.0 + let float = sin(phase * .pi * 2) * 0.6 + let blinkCycle = t.truncatingRemainder(dividingBy: 5.0) + let blink: CGFloat = (blinkCycle > 4.6 && blinkCycle < 4.8) ? 0.1 : 1.0 + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 16, svgY0: 2) + drawShadow(c, v: v, width: 10 + float * 0.3, opacity: 0.18) + drawBubbles(c, v: v, dy: float, scale: 0.95) + drawFace(c, v: v, dy: float, blinkPhase: blink) + } + } + + // ━━━━━━ WORK (Processing / Active State) ━━━━━━ + private var workScene: some View { + TimelineView(.periodic(from: .now, by: 0.03)) { ctx in + workCanvas(t: ctx.date.timeIntervalSinceReferenceDate * speed) + } + } + + private func workCanvas(t: Double) -> some View { + let bounce = sin(t * 2 * .pi / 0.4) * 0.8 + let blinkCycle = t.truncatingRemainder(dividingBy: 3.0) + let blink: CGFloat = (blinkCycle > 2.7 && blinkCycle < 2.85) ? 0.15 : 1.0 + + // Staggered typing dots sequence (1 -> 2 -> 3 -> idle -> 1) + let typingPhase = Int(t / 0.22) % 4 + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 16, svgY0: 2) + let dy = bounce + + let shadowW: CGFloat = 11 - abs(dy) * 0.3 + c.fill(Path(v.r(8.0 - shadowW / 2, 15.5, shadowW, 1.0)), + with: .color(.black.opacity(max(0.1, 0.30 - abs(dy) * 0.03)))) + + drawBubbles(c, v: v, dy: dy, scale: 1.0) + drawFace(c, v: v, dy: dy, blinkPhase: blink, typingPhase: typingPhase + 1) + } + } + + // ━━━━━━ ALERT (Notification / Action Required State) ━━━━━━ + private var alertScene: some View { + ZStack { + Circle() + .fill(Self.alertC.opacity(alive ? 0.12 : 0)) + .frame(width: size * 0.82) + .blur(radius: size * 0.04) + .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: alive) + + TimelineView(.periodic(from: .now, by: 0.03)) { ctx in + alertCanvas(t: ctx.date.timeIntervalSinceReferenceDate * speed) + } + } + } + + private func alertCanvas(t: Double) -> some View { + let cycle = t.truncatingRemainder(dividingBy: 3.5) + let pct = cycle / 3.5 + + let jumpY = lerp([ + (0, 0), (0.03, 0), (0.10, -0.8), (0.15, 1.2), + (0.175, -7.5), (0.20, -7.5), (0.25, 1.2), + (0.275, -5.5), (0.30, -5.5), (0.35, 0.8), + (0.375, -3.5), (0.40, -3.5), (0.45, 0.6), + (0.475, -1.8), (0.50, -1.8), (0.55, 0.2), + (0.62, 0), (1.0, 0), + ], at: pct) + + let shakeX: CGFloat = (pct > 0.15 && pct < 0.55) ? sin(pct * 90) * 0.5 : 0 + let pulseScale: CGFloat = (pct > 0.03 && pct < 0.55) + ? 1.0 + sin(pct * 25) * 0.12 : 1.0 + + let bangOp = lerp([ + (0, 0), (0.03, 1), (0.10, 1), (0.55, 1), (0.62, 0), (1.0, 0), + ], at: pct) + let bangScale = lerp([ + (0, 0.3), (0.03, 1.3), (0.10, 1.0), (0.55, 1.0), (0.62, 0.6), (1.0, 0.6), + ], at: pct) + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 16, svgY0: 2) + + let shadowW: CGFloat = 10 * (1.0 - abs(min(0, jumpY)) * 0.04) + c.fill(Path(v.r(8.0 - shadowW / 2, 15.5, shadowW, 1.0)), + with: .color(.black.opacity(max(0.08, 0.35 - abs(min(0, jumpY)) * 0.04)))) + + c.translateBy(x: shakeX * v.s, y: 0) + drawBubbles(c, v: v, dy: jumpY, scale: pulseScale) + drawFace(c, v: v, dy: jumpY, blinkPhase: pct > 0.03 && pct < 0.15 ? 1.3 : 1.0) + c.translateBy(x: -shakeX * v.s, y: 0) + + // ! mark + if bangOp > 0.01 { + let bw: CGFloat = 1.6 * bangScale + let bx: CGFloat = 13.5 + let by: CGFloat = 3.5 + jumpY * 0.15 + c.fill(Path(v.r(bx, by, bw, 3.2 * bangScale, dy: 0)), + with: .color(Self.alertC.opacity(bangOp))) + c.fill(Path(v.r(bx, by + 3.8 * bangScale, bw, 1.2 * bangScale, dy: 0)), + with: .color(Self.alertC.opacity(bangOp))) + } + } + } +} diff --git a/Sources/UniIsland/WidgetsView.swift b/Sources/UniIsland/WidgetsView.swift new file mode 100644 index 00000000..a51bd79e --- /dev/null +++ b/Sources/UniIsland/WidgetsView.swift @@ -0,0 +1,342 @@ +import SwiftUI +import UniIslandCore + +// MARK: - Pomodoro Card +struct PomodoroCard: View { + var appState: AppState + let session: SessionSnapshot + + @AppStorage(SettingsKey.contentFontSize) private var contentFontSize = SettingsDefaults.contentFontSize + private var fontSize: CGFloat { CGFloat(contentFontSize) } + + private var progress: Double { + let total = appState.pomodoroTotalDuration > 0 ? appState.pomodoroTotalDuration : 25.0 * 60.0 + return 1.0 - (appState.pomodoroRemaining / total) + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + // Left Mascot Column (Typing Cat!) + MascotView(source: "pomodoro", status: session.status, size: 36) + .frame(width: 36, height: 36) + + // Center Details + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 6) { + Text(appState.pomodoroLabel) + .font(.system(size: fontSize + 1, weight: .bold, design: .monospaced)) + .foregroundStyle(Color(red: 1.0, green: 0.45, blue: 0.2)) // Neon Orange + + Text("• Pomodoro") + .font(.system(size: fontSize - 1, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.45)) + } + + // Monospace Timer Readout + Text(appState.formattedRemainingTime()) + .font(.system(size: 20, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .scaleEffect(appState.pomodoroActive && !appState.pomodoroPaused ? 1.02 : 1.0) + .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: appState.pomodoroActive && !appState.pomodoroPaused) + + // Focus progress bar + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(.white.opacity(0.12)) + .frame(height: 6) + + Capsule() + .fill(LinearGradient( + colors: [Color(red: 1.0, green: 0.4, blue: 0.1), Color(red: 1.0, green: 0.2, blue: 0.6)], + startPoint: .leading, endPoint: .trailing + )) + .frame(width: geo.size.width * CGFloat(min(1.0, max(0.0, progress))), height: 6) + .shadow(color: Color(red: 1.0, green: 0.3, blue: 0.4).opacity(0.5), radius: 3) + } + } + .frame(height: 6) + } + + Spacer(minLength: 4) + + // Right Control Deck + HStack(spacing: 8) { + // Play / Pause Button + Button { + appState.togglePomodoro() + } label: { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(.white.opacity(0.08)) + .frame(width: 26, height: 26) + + Image(systemName: appState.pomodoroActive && !appState.pomodoroPaused ? "pause.fill" : "play.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(appState.pomodoroActive && !appState.pomodoroPaused ? Color.orange : Color.green) + } + } + .buttonStyle(.plain) + + // Reset Button + Button { + appState.resetPomodoro() + } label: { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(.white.opacity(0.08)) + .frame(width: 26, height: 26) + + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white.opacity(0.7)) + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.white.opacity(0.05)) + ) + } +} + + +// MARK: - Media Card +struct MediaCard: View { + var appState: AppState + let session: SessionSnapshot + + @State private var diskRotation = 0.0 + @AppStorage(SettingsKey.contentFontSize) private var contentFontSize = SettingsDefaults.contentFontSize + private var fontSize: CGFloat { CGFloat(contentFontSize) } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + // Spinning Record + ZStack { + Circle() + .fill(.black) + .frame(width: 34, height: 34) + .overlay( + Circle() + .strokeBorder(.white.opacity(0.15), lineWidth: 1) + ) + + // Record grooves + Circle() + .stroke(.white.opacity(0.08), lineWidth: 3) + .frame(width: 24, height: 24) + + // Center Label + Circle() + .fill(Color(red: 0.15, green: 0.85, blue: 0.35)) // Neon Green + .frame(width: 10, height: 10) + + // Spinning Note indicator + Image(systemName: "music.note") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.black) + } + .rotationEffect(.degrees(diskRotation)) + .onAppear { + if session.status == .processing { + withAnimation(.linear(duration: 4.0).repeatForever(autoreverses: false)) { + diskRotation = 360.0 + } + } + } + .onChange(of: session.status) { + if session.status == .processing { + withAnimation(.linear(duration: 4.0).repeatForever(autoreverses: false)) { + diskRotation = 360.0 + } + } else { + diskRotation = 0.0 + } + } + + // Song Details + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 6) { + Text("媒体控制器") + .font(.system(size: fontSize + 1, weight: .bold, design: .monospaced)) + .foregroundStyle(Color(red: 0.15, green: 0.85, blue: 0.35)) + + Text("• Now Playing") + .font(.system(size: fontSize - 1, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.45)) + } + + // Track & Artist + VStack(alignment: .leading, spacing: 1) { + Text(appState.mediaTrackName) + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + + Text(appState.mediaArtistName) + .font(.system(size: fontSize - 1, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + } + + Spacer(minLength: 4) + + // Media Control Deck + HStack(spacing: 6) { + // Prev + Button { + appState.controlMedia("previous") + } label: { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(.white.opacity(0.08)) + .frame(width: 24, height: 24) + Image(systemName: "backward.fill") + .font(.system(size: 9)) + .foregroundStyle(.white.opacity(0.85)) + } + } + .buttonStyle(.plain) + + // Play / Pause + Button { + appState.controlMedia("playpause") + } label: { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(.white.opacity(0.08)) + .frame(width: 24, height: 24) + Image(systemName: session.status == .processing ? "pause.fill" : "play.fill") + .font(.system(size: 9)) + .foregroundStyle(.white.opacity(0.85)) + } + } + .buttonStyle(.plain) + + // Next + Button { + appState.controlMedia("next") + } label: { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(.white.opacity(0.08)) + .frame(width: 24, height: 24) + Image(systemName: "forward.fill") + .font(.system(size: 9)) + .foregroundStyle(.white.opacity(0.85)) + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.white.opacity(0.05)) + ) + } +} + + +// MARK: - Calendar Card (SuperIsland Port) +struct CalendarCard: View { + var appState: AppState + let session: SessionSnapshot + + @AppStorage(SettingsKey.contentFontSize) private var contentFontSize = SettingsDefaults.contentFontSize + private var fontSize: CGFloat { CGFloat(contentFontSize) } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + // Retro 8-bit Calendar Icon + VStack(spacing: 0) { + // Red banner + Rectangle() + .fill(Color.red) + .frame(width: 30, height: 8) + + // White page + ZStack { + Rectangle() + .fill(.white) + .frame(width: 30, height: 22) + + Text(getDayString()) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(.black) + } + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(.white.opacity(0.2), lineWidth: 1) + ) + + // Details + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 6) { + Text("日程管理") + .font(.system(size: fontSize + 1, weight: .bold, design: .monospaced)) + .foregroundStyle(Color(red: 1.00, green: 0.35, blue: 0.35)) // Neon Red + + Text("• Calendar Planner") + .font(.system(size: fontSize - 1, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.45)) + } + + // Event Title + Text(appState.calendarEventTitle) + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + + // Time / Countdown label + Text(appState.calendarEventCountdown) + .font(.system(size: fontSize - 1, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.65)) + } + + Spacer(minLength: 4) + + // Join Link Button + if let link = appState.calendarJoinLink, !link.isEmpty { + Button { + if let url = URL(string: link) { + NSWorkspace.shared.open(url) + } + } label: { + Text("一键入会") + .font(.system(size: fontSize - 1, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(red: 0.2, green: 0.6, blue: 1.0)) + ) + .shadow(color: Color(red: 0.2, green: 0.6, blue: 1.0).opacity(0.4), radius: 3) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.white.opacity(0.05)) + ) + } + + private func getDayString() -> String { + let fmt = DateFormatter() + fmt.dateFormat = "dd" + return fmt.string(from: Date()) + } +} diff --git a/Sources/CodeIsland/WorkBuddyView.swift b/Sources/UniIsland/WorkBuddyView.swift similarity index 99% rename from Sources/CodeIsland/WorkBuddyView.swift rename to Sources/UniIsland/WorkBuddyView.swift index 5d45ce6f..35f3a569 100644 --- a/Sources/CodeIsland/WorkBuddyView.swift +++ b/Sources/UniIsland/WorkBuddyView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodeIslandCore +import UniIslandCore /// WorkBuddyBot — WorkBuddy mascot, teal rounded robot character. /// Teal-cyan #32E6B9 with rounded friendly aesthetic. diff --git a/Sources/CodeIslandBridge/main.swift b/Sources/UniIslandBridge/main.swift similarity index 97% rename from Sources/CodeIslandBridge/main.swift rename to Sources/UniIslandBridge/main.swift index 5e749dd4..bb7f982c 100644 --- a/Sources/CodeIslandBridge/main.swift +++ b/Sources/UniIslandBridge/main.swift @@ -1,18 +1,18 @@ // ============================================================ -// codeisland-bridge — Native Claude Code hook event forwarder +// uniisland-bridge — Native Claude Code hook event forwarder // ============================================================ // Replaces shell script + nc with: // • Proper JSON parsing (no string manipulation) // • Deep terminal environment detection (tmux, Kitty, iTerm, Ghostty) // • Native POSIX socket communication // • session_id validation (drop events without it) -// • CODEISLAND_SKIP env var support -// • Debug logging (CODEISLAND_DEBUG) +// • UNIISLAND_SKIP env var support +// • Debug logging (UNIISLAND_DEBUG) // ============================================================ import Foundation import Darwin -import CodeIslandCore +import UniIslandCore // MARK: - Global Safety Net @@ -99,10 +99,10 @@ func buildAncestry(startingAt pid: pid_t, maxDepth: Int = 6) -> [(pid: pid_t, ex } func debugLog(_ message: String) { - guard ProcessInfo.processInfo.environment["CODEISLAND_DEBUG"] != nil else { return } + guard ProcessInfo.processInfo.environment["UNIISLAND_DEBUG"] != nil else { return } let ts = ISO8601DateFormatter().string(from: Date()) let line = "[\(ts)] \(message)\n" - let path = "/tmp/codeisland-bridge.log" + let path = "/tmp/uniisland-bridge.log" if let handle = FileHandle(forWritingAtPath: path) { handle.seekToEndOfFile() handle.write(Data(line.utf8)) @@ -219,8 +219,8 @@ if let idx = args.firstIndex(of: "--event"), idx + 1 < args.count { eventTag = args[idx + 1] } -// Quick exit: skip if CODEISLAND_SKIP is set -guard env["CODEISLAND_SKIP"] == nil else { exit(0) } +// Quick exit: skip if UNIISLAND_SKIP is set +guard env["UNIISLAND_SKIP"] == nil else { exit(0) } // Quick exit: socket doesn't exist or isn't a socket var statBuf = stat() diff --git a/Sources/CodeIslandCore/ChatMessageTextFormatter.swift b/Sources/UniIslandCore/ChatMessageTextFormatter.swift similarity index 100% rename from Sources/CodeIslandCore/ChatMessageTextFormatter.swift rename to Sources/UniIslandCore/ChatMessageTextFormatter.swift diff --git a/Sources/CodeIslandCore/CodexAppServerClient.swift b/Sources/UniIslandCore/CodexAppServerClient.swift similarity index 99% rename from Sources/CodeIslandCore/CodexAppServerClient.swift rename to Sources/UniIslandCore/CodexAppServerClient.swift index 80736c0a..a1304c93 100644 --- a/Sources/CodeIslandCore/CodexAppServerClient.swift +++ b/Sources/UniIslandCore/CodexAppServerClient.swift @@ -132,7 +132,7 @@ public final class CodexAppServerClient: @unchecked Sendable { self.executableURL = executableURL self.arguments = arguments self.callbackQueue = callbackQueue - self.ioQueue = DispatchQueue(label: "com.codeisland.codex-app-server-io") + self.ioQueue = DispatchQueue(label: "com.uniisland.codex-app-server-io") } /// Default location of the Codex binary bundled with the desktop app. diff --git a/Sources/CodeIslandCore/ESP32Protocol.swift b/Sources/UniIslandCore/ESP32Protocol.swift similarity index 98% rename from Sources/CodeIslandCore/ESP32Protocol.swift rename to Sources/UniIslandCore/ESP32Protocol.swift index 2df954f8..07f4ff6d 100644 --- a/Sources/CodeIslandCore/ESP32Protocol.swift +++ b/Sources/UniIslandCore/ESP32Protocol.swift @@ -206,7 +206,7 @@ public enum MascotID: UInt8, CaseIterable, Sendable { case hermes = 14 case kimi = 15 - /// Canonical source name used throughout CodeIsland (matches + /// Canonical source name used throughout UniIsland (matches /// `SessionSnapshot.supportedSources` keys). public var sourceName: String { switch self { @@ -229,7 +229,7 @@ public enum MascotID: UInt8, CaseIterable, Sendable { } } - /// Fold a CodeIsland source string (including aliases like `traecn`, + /// Fold a UniIsland source string (including aliases like `traecn`, /// `traecli`, `codybuddycn`, `factory`, `ag`) into one of the 16 slots /// supported by the Buddy firmware. public init?(sourceName: String?) { @@ -291,7 +291,7 @@ public struct MascotFramePayload: Equatable, Sendable { self.toolName = toolName } - /// Build a frame from a canonical source string + CodeIsland AgentStatus. + /// Build a frame from a canonical source string + UniIsland AgentStatus. /// Returns `nil` if `source` doesn't fold to a known mascot slot. public init?(source: String?, status: AgentStatus, toolName: String? = nil) { guard let mascot = MascotID(sourceName: source) else { return nil } diff --git a/Sources/CodeIslandCore/EventNormalizer.swift b/Sources/UniIslandCore/EventNormalizer.swift similarity index 100% rename from Sources/CodeIslandCore/EventNormalizer.swift rename to Sources/UniIslandCore/EventNormalizer.swift diff --git a/Sources/CodeIslandCore/JSONLTailer.swift b/Sources/UniIslandCore/JSONLTailer.swift similarity index 99% rename from Sources/CodeIslandCore/JSONLTailer.swift rename to Sources/UniIslandCore/JSONLTailer.swift index 2950d48f..9e81bc53 100644 --- a/Sources/CodeIslandCore/JSONLTailer.swift +++ b/Sources/UniIslandCore/JSONLTailer.swift @@ -58,7 +58,7 @@ public final class JSONLTailer: @unchecked Sendable { private var watches: [String: Watch] = [:] public init( - queue: DispatchQueue = DispatchQueue(label: "com.codeisland.jsonl-tailer"), + queue: DispatchQueue = DispatchQueue(label: "com.uniisland.jsonl-tailer"), onDelta: @escaping DeltaHandler ) { self.queue = queue diff --git a/Sources/CodeIslandCore/Models.swift b/Sources/UniIslandCore/Models.swift similarity index 99% rename from Sources/CodeIslandCore/Models.swift rename to Sources/UniIslandCore/Models.swift index 5b67fdf9..d3a1fe49 100644 --- a/Sources/CodeIslandCore/Models.swift +++ b/Sources/UniIslandCore/Models.swift @@ -39,7 +39,7 @@ public enum CLIProcessResolver { /// When the caller passed `--source cursor` or `--source qoder` but the /// process ancestry actually came from the CLI agent rather than the /// desktop IDE (both write to the same hooks file — see issue #134), - /// promote the source to its `-cli` variant so CodeIsland renders it + /// promote the source to its `-cli` variant so UniIsland renders it /// as "Cursor CLI" / "Qoder CLI" and routes terminal jumps correctly. public static func cliVariantOverride( declaredSource: String?, diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/UniIslandCore/SessionSnapshot.swift similarity index 99% rename from Sources/CodeIslandCore/SessionSnapshot.swift rename to Sources/UniIslandCore/SessionSnapshot.swift index b5a627e9..039564bf 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/UniIslandCore/SessionSnapshot.swift @@ -213,6 +213,9 @@ public struct SessionSnapshot: Sendable { /// Display name: project folder, or short session ID public var displayName: String { + // WeChat notification pseudo-session: show a clean "wechat" label, not the .app path. + if source == "wechat" { return "wechat" } + if source == "drops" { return "Drops" } if let cwd = cwd { let last = (cwd as NSString).lastPathComponent // If last component is a timestamp/numeric ID (e.g. CodeBuddy "20260406010126"), @@ -301,6 +304,8 @@ public struct SessionSnapshot: Sendable { case "pi": return "pi" case "kiro": return "Kiro" case "cline": return "Cline" + case "wechat": return "微信" + case "drops": return "Drops" default: if let customName = Self.loadCustomSourceNames()[source] { return customName diff --git a/Sources/CodeIslandCore/SocketPath.swift b/Sources/UniIslandCore/SocketPath.swift similarity index 50% rename from Sources/CodeIslandCore/SocketPath.swift rename to Sources/UniIslandCore/SocketPath.swift index c07e3259..8ea9a3ca 100644 --- a/Sources/CodeIslandCore/SocketPath.swift +++ b/Sources/UniIslandCore/SocketPath.swift @@ -3,9 +3,9 @@ import Darwin public enum SocketPath { public static var path: String { - if let env = ProcessInfo.processInfo.environment["CODEISLAND_SOCKET_PATH"] { + if let env = ProcessInfo.processInfo.environment["UNIISLAND_SOCKET_PATH"] { return env } - return "/tmp/codeisland-\(getuid()).sock" + return "/tmp/uniisland-\(getuid()).sock" } } diff --git a/Sources/CodeIslandCore/WarpPaneResolver.swift b/Sources/UniIslandCore/WarpPaneResolver.swift similarity index 100% rename from Sources/CodeIslandCore/WarpPaneResolver.swift rename to Sources/UniIslandCore/WarpPaneResolver.swift diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift deleted file mode 100644 index 498b20de..00000000 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ /dev/null @@ -1,152 +0,0 @@ -import XCTest -@testable import CodeIsland - -final class NotchPanelViewTests: XCTestCase { - func testEffectiveNotchWidthAppliesCollapsedWidthScale() { - XCTAssertEqual( - NotchWidthMetrics.effectiveNotchWidth(notchW: 200, collapsedWidthScale: 50), - 100, - accuracy: 0.001 - ) - XCTAssertEqual( - NotchWidthMetrics.effectiveNotchWidth(notchW: 200, collapsedWidthScale: 150), - 300, - accuracy: 0.001 - ) - } - - func testEffectiveNotchWidthClampsOutOfRangeScale() { - XCTAssertEqual( - NotchWidthMetrics.effectiveNotchWidth(notchW: 200, collapsedWidthScale: 10), - 100, - accuracy: 0.001 - ) - XCTAssertEqual( - NotchWidthMetrics.effectiveNotchWidth(notchW: 200, collapsedWidthScale: 250), - 300, - accuracy: 0.001 - ) - } - - func testShouldTriggerJumpFailureFeedbackWhenAllAttemptsFail() { - XCTAssertTrue(shouldTriggerJumpFailureFeedback([false, false, false])) - } - - func testShouldNotTriggerJumpFailureFeedbackWhenAnyAttemptSucceeds() { - XCTAssertFalse(shouldTriggerJumpFailureFeedback([false, true, false])) - } - - func testJumpFailureShakeSequenceUsesFastAlternatingOffsets() { - XCTAssertEqual(JumpAnimationHelper.shakeSequence, [8, -8, 6, -6, 3, -3, 0]) - } - - func testEvaluateJumpValidationReturnsSuccessWhenCheckSucceeds() async { - var callCount = 0 - let outcome = await evaluateJumpValidation( - delays: [1, 1, 1], - isCancelled: { false }, - sleep: { _ in }, - checkSucceeded: { - callCount += 1 - return callCount == 2 - } - ) - - XCTAssertEqual(outcome, .success) - } - - func testEvaluateJumpValidationReturnsFailedWhenAllChecksFail() async { - let outcome = await evaluateJumpValidation( - delays: [1, 1, 1], - isCancelled: { false }, - sleep: { _ in }, - checkSucceeded: { false } - ) - - XCTAssertEqual(outcome, .failed) - } - - func testEvaluateJumpValidationReturnsCancelledBeforeCheckRuns() async { - var checksRan = 0 - let outcome = await evaluateJumpValidation( - delays: [1, 1, 1], - isCancelled: { true }, - sleep: { _ in }, - checkSucceeded: { - checksRan += 1 - return false - } - ) - - XCTAssertEqual(outcome, .cancelled) - XCTAssertEqual(checksRan, 0) - } - - func testClickJumpCollapseTimelineShowsClickRingWhenCursorReachesClickPoint() { - let timeline = clickJumpCollapsePreviewTimeline(progress: 0.26) - - XCTAssertGreaterThan(timeline.expand, 0.95) - XCTAssertTrue(timeline.showClickRing) - XCTAssertEqual(timeline.cursorX, 0, accuracy: 0.001) - XCTAssertEqual(timeline.cursorY, 0, accuracy: 0.001) - } - - func testClickJumpCollapseTimelineMovesCursorToClickPointFaster() { - let timeline = clickJumpCollapsePreviewTimeline(progress: 0.08) - - XCTAssertEqual(timeline.cursorX, 0, accuracy: 0.001) - XCTAssertEqual(timeline.cursorY, 0, accuracy: 0.001) - } - - func testClickJumpCollapseTimelineMovesCursorFullyOffscreenBeforeExpandStarts() { - let timeline = clickJumpCollapsePreviewTimeline(progress: 0.80) - - XCTAssertEqual(timeline.cursorX, 34, accuracy: 0.001) - XCTAssertEqual(timeline.cursorY, 28, accuracy: 0.001) - XCTAssertLessThanOrEqual(timeline.expand, 0.001) - } - - func testClickJumpCollapseTimelineStartsExpandAfterCursorIsAlreadyOffscreen() { - let timeline = clickJumpCollapsePreviewTimeline(progress: 0.85) - - XCTAssertGreaterThan(timeline.expand, 0.3) - XCTAssertEqual(timeline.cursorX, 34, accuracy: 0.001) - XCTAssertEqual(timeline.cursorY, 28, accuracy: 0.001) - } - - func testClickJumpCollapseTimelineUsesMouseLeaveLikeCollapseSpeed() { - let timeline = clickJumpCollapsePreviewTimeline(progress: 0.38) - - XCTAssertGreaterThan(timeline.expand, 0.5) - XCTAssertLessThan(timeline.expand, 0.7) - } - - func testClickJumpCollapseTimelineUsesMouseLeaveLikeExpandSpeed() { - let timeline = clickJumpCollapsePreviewTimeline(progress: 0.93) - - XCTAssertGreaterThanOrEqual(timeline.expand, 0.999) - } - - func testClickJumpCollapseTimelineHoldsCollapsedStateForMiddleWindow() { - let timeline = clickJumpCollapsePreviewTimeline(progress: 0.60) - - XCTAssertLessThanOrEqual(timeline.expand, 0.001) - XCTAssertEqual(timeline.cursorX, 0, accuracy: 0.001) - XCTAssertEqual(timeline.cursorY, 0, accuracy: 0.001) - } - - func testClickJumpCollapseTimelineLoopSeamIsSmooth() { - let start = clickJumpCollapsePreviewTimeline(progress: 0) - let end = clickJumpCollapsePreviewTimeline(progress: 1) - - XCTAssertEqual(start.expand, end.expand, accuracy: 0.001) - XCTAssertEqual(start.cursorX, end.cursorX, accuracy: 0.001) - XCTAssertEqual(start.cursorY, end.cursorY, accuracy: 0.001) - } - - func testClickJumpCollapseTimelineLowersClickPoint() { - let timeline = clickJumpCollapsePreviewTimeline(progress: 0.26) - XCTAssertEqual(timeline.clickPointY, 16.0, accuracy: 0.1) - } - -} diff --git a/Tests/CodeIslandCoreTests/CLIProcessResolverTests.swift b/Tests/UniIslandCoreTests/CLIProcessResolverTests.swift similarity index 99% rename from Tests/CodeIslandCoreTests/CLIProcessResolverTests.swift rename to Tests/UniIslandCoreTests/CLIProcessResolverTests.swift index 339a4d25..5df1abf1 100644 --- a/Tests/CodeIslandCoreTests/CLIProcessResolverTests.swift +++ b/Tests/UniIslandCoreTests/CLIProcessResolverTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIslandCore +@testable import UniIslandCore final class CLIProcessResolverTests: XCTestCase { diff --git a/Tests/CodeIslandCoreTests/ChatMessageTextFormatterTests.swift b/Tests/UniIslandCoreTests/ChatMessageTextFormatterTests.swift similarity index 98% rename from Tests/CodeIslandCoreTests/ChatMessageTextFormatterTests.swift rename to Tests/UniIslandCoreTests/ChatMessageTextFormatterTests.swift index f78c01b8..986382a3 100644 --- a/Tests/CodeIslandCoreTests/ChatMessageTextFormatterTests.swift +++ b/Tests/UniIslandCoreTests/ChatMessageTextFormatterTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIslandCore +@testable import UniIslandCore final class ChatMessageTextFormatterTests: XCTestCase { func testUserMessagesStayLiteralEvenWhenTheyContainMarkdownSyntax() { diff --git a/Tests/CodeIslandCoreTests/CodexAppServerClientTests.swift b/Tests/UniIslandCoreTests/CodexAppServerClientTests.swift similarity index 99% rename from Tests/CodeIslandCoreTests/CodexAppServerClientTests.swift rename to Tests/UniIslandCoreTests/CodexAppServerClientTests.swift index 7d5ddb85..ddf5f35d 100644 --- a/Tests/CodeIslandCoreTests/CodexAppServerClientTests.swift +++ b/Tests/UniIslandCoreTests/CodexAppServerClientTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIslandCore +@testable import UniIslandCore final class CodexAppServerClientTests: XCTestCase { diff --git a/Tests/CodeIslandCoreTests/CodexNativeSubagentRoutingTests.swift b/Tests/UniIslandCoreTests/CodexNativeSubagentRoutingTests.swift similarity index 98% rename from Tests/CodeIslandCoreTests/CodexNativeSubagentRoutingTests.swift rename to Tests/UniIslandCoreTests/CodexNativeSubagentRoutingTests.swift index a77e7978..53649d37 100644 --- a/Tests/CodeIslandCoreTests/CodexNativeSubagentRoutingTests.swift +++ b/Tests/UniIslandCoreTests/CodexNativeSubagentRoutingTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIslandCore +@testable import UniIslandCore final class CodexNativeSubagentRoutingTests: XCTestCase { func testCodexSubagentSessionStartDoesNotResetParentSession() throws { diff --git a/Tests/CodeIslandCoreTests/DerivedSessionStateTests.swift b/Tests/UniIslandCoreTests/DerivedSessionStateTests.swift similarity index 99% rename from Tests/CodeIslandCoreTests/DerivedSessionStateTests.swift rename to Tests/UniIslandCoreTests/DerivedSessionStateTests.swift index 1026d2e0..cf92c363 100644 --- a/Tests/CodeIslandCoreTests/DerivedSessionStateTests.swift +++ b/Tests/UniIslandCoreTests/DerivedSessionStateTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIslandCore +@testable import UniIslandCore final class DerivedSessionStateTests: XCTestCase { func testAllIdleSessionsUseMostRecentlyActiveSource() { diff --git a/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift b/Tests/UniIslandCoreTests/ESP32ProtocolTests.swift similarity index 98% rename from Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift rename to Tests/UniIslandCoreTests/ESP32ProtocolTests.swift index a504398d..56f90901 100644 --- a/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift +++ b/Tests/UniIslandCoreTests/ESP32ProtocolTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIslandCore +@testable import UniIslandCore final class ESP32ProtocolTests: XCTestCase { // MARK: - Source folding @@ -97,11 +97,11 @@ final class ESP32ProtocolTests: XCTestCase { } func testEncodeWorkspaceFrame() { - let frame = BuddyWorkspacePayload(workspaceName: "CodeIsland") + let frame = BuddyWorkspacePayload(workspaceName: "UniIsland") let data = frame.encode() XCTAssertEqual(data[0], ESP32Protocol.workspaceFrameMarker) XCTAssertEqual(data[1], 10) - XCTAssertEqual(String(data: data.subdata(in: 2.. URL { codexHome .appendingPathComponent("rules", isDirectory: true) - .appendingPathComponent("codeisland.rules") + .appendingPathComponent("uniisland.rules") } - private func readCodeIslandRules(in codexHome: URL) throws -> String { + private func readUniIslandRules(in codexHome: URL) throws -> String { try String(contentsOf: codeIslandRulesPath(in: codexHome), encoding: .utf8) } diff --git a/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift b/Tests/UniIslandTests/AppStatePrimarySourceTests.swift similarity index 99% rename from Tests/CodeIslandTests/AppStatePrimarySourceTests.swift rename to Tests/UniIslandTests/AppStatePrimarySourceTests.swift index dfeedea8..a2a70474 100644 --- a/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift +++ b/Tests/UniIslandTests/AppStatePrimarySourceTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import CodeIsland -import CodeIslandCore +@testable import UniIsland +import UniIslandCore @MainActor final class AppStatePrimarySourceTests: XCTestCase { diff --git a/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift b/Tests/UniIslandTests/AppStateQuestionFlowTests.swift similarity index 99% rename from Tests/CodeIslandTests/AppStateQuestionFlowTests.swift rename to Tests/UniIslandTests/AppStateQuestionFlowTests.swift index 2ea3dc79..f421497e 100644 --- a/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift +++ b/Tests/UniIslandTests/AppStateQuestionFlowTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import CodeIsland -import CodeIslandCore +@testable import UniIsland +import UniIslandCore @MainActor final class AppStateQuestionFlowTests: XCTestCase { diff --git a/Tests/CodeIslandTests/AppStateToolUseCacheTests.swift b/Tests/UniIslandTests/AppStateToolUseCacheTests.swift similarity index 99% rename from Tests/CodeIslandTests/AppStateToolUseCacheTests.swift rename to Tests/UniIslandTests/AppStateToolUseCacheTests.swift index 87544724..3814cd02 100644 --- a/Tests/CodeIslandTests/AppStateToolUseCacheTests.swift +++ b/Tests/UniIslandTests/AppStateToolUseCacheTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import CodeIsland -import CodeIslandCore +@testable import UniIsland +import UniIslandCore @MainActor final class AppStateToolUseCacheTests: XCTestCase { diff --git a/Tests/CodeIslandTests/CodexHomeTests.swift b/Tests/UniIslandTests/CodexHomeTests.swift similarity index 97% rename from Tests/CodeIslandTests/CodexHomeTests.swift rename to Tests/UniIslandTests/CodexHomeTests.swift index 02812282..3aa7dd1e 100644 --- a/Tests/CodeIslandTests/CodexHomeTests.swift +++ b/Tests/UniIslandTests/CodexHomeTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland final class CodexHomeTests: XCTestCase { private var savedValue: String? @@ -115,7 +115,7 @@ final class CodexHomeTests: XCTestCase { private func makeTemporaryCodexHome() -> URL { let url = FileManager.default.temporaryDirectory - .appendingPathComponent("codeisland-codex-home-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("uniisland-codex-home-\(UUID().uuidString)", isDirectory: true) setenv("CODEX_HOME", url.path, 1) return url } diff --git a/Tests/CodeIslandTests/ConfigInstallerTests.swift b/Tests/UniIslandTests/ConfigInstallerTests.swift similarity index 88% rename from Tests/CodeIslandTests/ConfigInstallerTests.swift rename to Tests/UniIslandTests/ConfigInstallerTests.swift index 3abc1a2e..0a478320 100644 --- a/Tests/CodeIslandTests/ConfigInstallerTests.swift +++ b/Tests/UniIslandTests/ConfigInstallerTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import CodeIsland -import CodeIslandCore +@testable import UniIsland +import UniIslandCore import Yams final class ConfigInstallerTests: XCTestCase { @@ -41,7 +41,7 @@ final class ConfigInstallerTests: XCTestCase { "matcher": "", "hooks": [ [ - "command": "~/.claude/hooks/codeisland-hook.sh", + "command": "~/.claude/hooks/uniisland-hook.sh", "timeout": 5, "type": "command", ], @@ -93,7 +93,7 @@ final class ConfigInstallerTests: XCTestCase { let hooks = try XCTUnwrap(root["hooks"] as? [String: Any]) let entries = try XCTUnwrap(hooks["afterAgentResponse"] as? [[String: Any]]) let command = try XCTUnwrap(entries.first?["command"] as? String) - XCTAssertTrue(command.contains("codeisland-bridge --source cursor")) + XCTAssertTrue(command.contains("uniisland-bridge --source cursor")) XCTAssertTrue(command.contains("--event afterAgentResponse")) } @@ -120,19 +120,19 @@ final class ConfigInstallerTests: XCTestCase { let hooks = try XCTUnwrap(root["hooks"] as? [String: Any]) let entries = try XCTUnwrap(hooks["stop"] as? [[String: Any]]) let command = try XCTUnwrap(entries.first?["command"] as? String) - XCTAssertTrue(command.contains("codeisland-bridge --source traecli")) + XCTAssertTrue(command.contains("uniisland-bridge --source traecli")) XCTAssertTrue(command.contains("--event stop")) } // MARK: - Kimi Code CLI TOML hooks - func testRemoveKimiHooksPreservesNonCodeIslandBlocks() { + func testRemoveKimiHooksPreservesNonUniIslandBlocks() { let toml = """ default_model = "kimi-k2-5" [[hooks]] event = "Stop" - command = "/Users/test/.codeisland/codeisland-bridge --source kimi" + command = "/Users/test/.uniisland/uniisland-bridge --source kimi" timeout = 5 [[mcpServers]] @@ -146,7 +146,7 @@ final class ConfigInstallerTests: XCTestCase { """ let cleaned = ConfigInstaller.removeKimiHooks(from: toml) - XCTAssertFalse(cleaned.contains("codeisland-bridge")) + XCTAssertFalse(cleaned.contains("uniisland-bridge")) XCTAssertTrue(cleaned.contains("[[mcpServers]]")) XCTAssertTrue(cleaned.contains("echo hello")) XCTAssertTrue(cleaned.contains("default_model")) @@ -156,13 +156,13 @@ final class ConfigInstallerTests: XCTestCase { let toml = """ [[hooks]] event = "PreToolUse" - command = "/Users/test/.codeisland/codeisland-bridge --source kimi" + command = "/Users/test/.uniisland/uniisland-bridge --source kimi" timeout = 5 matcher = ".*" [[hooks]] event = "Stop" - command = "/Users/test/.codeisland/codeisland-bridge --source kimi" + command = "/Users/test/.uniisland/uniisland-bridge --source kimi" timeout = 5 """ @@ -219,7 +219,7 @@ final class ConfigInstallerTests: XCTestCase { XCTAssertTrue(installed.contains("[[hooks]]")) XCTAssertTrue(installed.contains("event = \"PreToolUse\"")) XCTAssertTrue(installed.contains("event = \"Stop\"")) - XCTAssertTrue(installed.contains("codeisland-bridge --source kimi")) + XCTAssertTrue(installed.contains("uniisland-bridge --source kimi")) XCTAssertFalse(installed.contains("\nhooks = "), "Scalar hooks key should be commented out to avoid TOML duplicate key error") XCTAssertTrue(installed.contains("# hooks ="), "Legacy scalar hooks should be preserved as comments") @@ -229,7 +229,7 @@ final class ConfigInstallerTests: XCTestCase { let uninstalled = try XCTUnwrap(String(data: uninstalledData, encoding: .utf8)) XCTAssertTrue(uninstalled.contains("hooks = [\"UserPromptSubmit\"]"), "Legacy scalar hooks should be restored after uninstall") - XCTAssertFalse(uninstalled.contains("codeisland-bridge"), "CodeIsland hooks should be removed after uninstall") + XCTAssertFalse(uninstalled.contains("uniisland-bridge"), "UniIsland hooks should be removed after uninstall") } func testMergeCocoHooksAppendsHooksSectionWhenMissing() { @@ -240,7 +240,7 @@ final class ConfigInstallerTests: XCTestCase { let hooks = try! yamlHooks(merged) XCTAssertEqual(hooks.count, 1) let cmd = hooks.first?["command"] as? String - XCTAssertTrue(cmd?.contains("codeisland-bridge --source traecli") ?? false) + XCTAssertTrue(cmd?.contains("uniisland-bridge --source traecli") ?? false) // Managed block should be a SINGLE hook with multiple matchers. TraeCli may de-dup by // (type + command), so emitting one hook per event can drop most events. @@ -260,7 +260,7 @@ hooks: matchers: - event: stop - type: command - command: '\(NSHomeDirectory())/.codeisland/codeisland-bridge --source traecli' + command: '\(NSHomeDirectory())/.uniisland/uniisland-bridge --source traecli' timeout: '86400s' matchers: - event: session_start @@ -285,12 +285,12 @@ hooks: XCTAssertTrue(commands.contains("echo user-hook")) // New managed block should still contain a traecli bridge command. - XCTAssertEqual(commands.filter { $0.contains("codeisland-bridge") && $0.contains("--source traecli") }.count, 1) + XCTAssertEqual(commands.filter { $0.contains("uniisland-bridge") && $0.contains("--source traecli") }.count, 1) XCTAssertEqual(hooks.count, 2) } func testMergeTraecliHooksRemovesQuotedBridgeCommandToAvoidDuplicates() { - let bridge = "\(NSHomeDirectory())/.codeisland/codeisland-bridge" + let bridge = "\(NSHomeDirectory())/.uniisland/uniisland-bridge" let original = """ hooks: - type: command @@ -316,7 +316,7 @@ hooks: let hooks = try! yamlHooks(merged) let commands = hooks.compactMap { $0["command"] as? String } - XCTAssertEqual(commands.filter { $0.contains("codeisland-bridge") }.count, 1) + XCTAssertEqual(commands.filter { $0.contains("uniisland-bridge") }.count, 1) XCTAssertEqual(commands.filter { $0.contains("--source traecli") }.count, 1) } @@ -348,7 +348,7 @@ hooks: let hooks = try! yamlHooks(merged) let commands = hooks.compactMap { $0["command"] as? String } XCTAssertTrue(commands.contains("echo user-hook")) - XCTAssertEqual(commands.filter { $0.contains("codeisland-bridge") && $0.contains("--source traecli") }.count, 1) + XCTAssertEqual(commands.filter { $0.contains("uniisland-bridge") && $0.contains("--source traecli") }.count, 1) XCTAssertEqual(hooks.count, 2) } @@ -398,7 +398,7 @@ hooks: let original = """ hooks: - type: command # keep - command: '\(NSHomeDirectory())/.codeisland/codeisland-bridge --source traecli' + command: '\(NSHomeDirectory())/.uniisland/uniisland-bridge --source traecli' timeout: '86400s' matchers: - event: session_start @@ -432,19 +432,19 @@ hooks: let original = """ hooks: # any legacy marker/comment line should be removed with the hook - # CODEISLAND_MANAGED_TRAECLI_HOOK_BEGIN + # UNIISLAND_MANAGED_TRAECLI_HOOK_BEGIN - type: command - command: '\(NSHomeDirectory())/.codeisland/codeisland-bridge --source traecli' + command: '\(NSHomeDirectory())/.uniisland/uniisland-bridge --source traecli' matchers: - event: stop - # CODEISLAND_MANAGED_TRAECLI_HOOK_END + # UNIISLAND_MANAGED_TRAECLI_HOOK_END # trailing comment should also be removed """ let cleaned = ConfigInstaller.removeManagedTraecliHooks(from: original) - XCTAssertFalse(cleaned.contains("codeisland-bridge --source traecli")) - XCTAssertFalse(cleaned.contains("CODEISLAND_MANAGED_TRAECLI_HOOK")) + XCTAssertFalse(cleaned.contains("uniisland-bridge --source traecli")) + XCTAssertFalse(cleaned.contains("UNIISLAND_MANAGED_TRAECLI_HOOK")) XCTAssertFalse(cleaned.contains("trailing comment")) } @@ -483,7 +483,7 @@ hooks: let script = RemoteInstaller.configureRemoteHooksScript(host: host) XCTAssertTrue(script.contains("def install_opencode():")) - XCTAssertTrue(script.contains("codeisland-opencode-remote.js")) + XCTAssertTrue(script.contains("uniisland-opencode-remote.js")) XCTAssertTrue(script.contains(#""OpenCode ok""#)) XCTAssertTrue(script.contains("install_opencode()")) XCTAssertTrue(script.contains(#""file://" + str(plugin_path)"#)) @@ -505,28 +505,28 @@ hooks: func testRemoteOpencodePluginCarriesRemoteHostIdentity() throws { let host = RemoteHost(id: #"host-"quoted""#, name: "devbox\nwest", host: "example.com") let source = """ - const SOCKET_PATH = process.env.CODEISLAND_SOCKET_PATH || "/tmp/codeisland.sock"; - const REMOTE_HOST_ID = process.env.CODEISLAND_REMOTE_HOST_ID || ""; - const REMOTE_HOST_NAME = process.env.CODEISLAND_REMOTE_HOST_NAME || ""; + const SOCKET_PATH = process.env.UNIISLAND_SOCKET_PATH || "/tmp/uniisland.sock"; + const REMOTE_HOST_ID = process.env.UNIISLAND_REMOTE_HOST_ID || ""; + const REMOTE_HOST_NAME = process.env.UNIISLAND_REMOTE_HOST_NAME || ""; """ let plugin = RemoteInstaller.remoteOpencodePluginForInstall(source: source, host: host) - XCTAssertTrue(plugin.contains(#"const SOCKET_PATH = "/tmp/codeisland.sock";"#)) + XCTAssertTrue(plugin.contains(#"const SOCKET_PATH = "/tmp/uniisland.sock";"#)) XCTAssertTrue(plugin.contains(#"const REMOTE_HOST_ID = "host-\"quoted\"";"#)) XCTAssertTrue(plugin.contains(#"const REMOTE_HOST_NAME = "devbox\nwest";"#)) } func testRemoteInstallerConfigureScriptInjectsPerUserSocketPath() { // #193: on a shared remote host the hook command must point at a uid-scoped - // socket path so different OS users don't collide on /tmp/codeisland.sock. + // socket path so different OS users don't collide on /tmp/uniisland.sock. let host = RemoteHost(id: "host-1", name: "devbox", host: "example.com") - let script = RemoteInstaller.configureRemoteHooksScript(host: host, remoteSocketPath: "/tmp/codeisland-1000.sock") + let script = RemoteInstaller.configureRemoteHooksScript(host: host, remoteSocketPath: "/tmp/uniisland-1000.sock") - XCTAssertTrue(script.contains(#"socket_path = "/tmp/codeisland-1000.sock""#)) - XCTAssertTrue(script.contains("CODEISLAND_SOCKET_PATH={socket_path}")) - XCTAssertFalse(script.contains("CODEISLAND_SOCKET_PATH=/tmp/codeisland.sock")) + XCTAssertTrue(script.contains(#"socket_path = "/tmp/uniisland-1000.sock""#)) + XCTAssertTrue(script.contains("UNIISLAND_SOCKET_PATH={socket_path}")) + XCTAssertFalse(script.contains("UNIISLAND_SOCKET_PATH=/tmp/uniisland.sock")) } func testRemoteInstallerConfigureScriptFallsBackToLegacySocketPath() { @@ -535,17 +535,17 @@ hooks: let script = RemoteInstaller.configureRemoteHooksScript(host: host) - XCTAssertTrue(script.contains(#"socket_path = "/tmp/codeisland.sock""#)) + XCTAssertTrue(script.contains(#"socket_path = "/tmp/uniisland.sock""#)) } func testRemoteOpencodePluginInjectsPerUserSocketPath() { // #193 let host = RemoteHost(id: "host-1", name: "devbox", host: "example.com") - let source = #"const SOCKET_PATH = process.env.CODEISLAND_SOCKET_PATH || "/tmp/codeisland.sock";"# + let source = #"const SOCKET_PATH = process.env.UNIISLAND_SOCKET_PATH || "/tmp/uniisland.sock";"# - let plugin = RemoteInstaller.remoteOpencodePluginForInstall(source: source, host: host, remoteSocketPath: "/tmp/codeisland-1000.sock") + let plugin = RemoteInstaller.remoteOpencodePluginForInstall(source: source, host: host, remoteSocketPath: "/tmp/uniisland-1000.sock") - XCTAssertTrue(plugin.contains(#"const SOCKET_PATH = "/tmp/codeisland-1000.sock";"#)) + XCTAssertTrue(plugin.contains(#"const SOCKET_PATH = "/tmp/uniisland-1000.sock";"#)) } func testRemoteInstallerConfigureScriptInstallsCustomClaudeCLI() throws { @@ -674,13 +674,13 @@ hooks: let merged = try XCTUnwrap( ConfigInstaller.mergeOpencodePluginRef( originalContents: nil, - pluginRef: "file:///tmp/codeisland.js", - identifier: "codeisland" + pluginRef: "file:///tmp/uniisland.js", + identifier: "uniisland" ) ) let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(merged.utf8)) as? [String: Any]) XCTAssertEqual(json["$schema"] as? String, "https://opencode.ai/config.json") - XCTAssertEqual(json["plugin"] as? [String], ["file:///tmp/codeisland.js"]) + XCTAssertEqual(json["plugin"] as? [String], ["file:///tmp/uniisland.js"]) } func testMergeOpencodePluginRefPreservesUnrelatedKeysAndOtherPlugins() throws { @@ -695,8 +695,8 @@ hooks: let merged = try XCTUnwrap( ConfigInstaller.mergeOpencodePluginRef( originalContents: original, - pluginRef: "file:///tmp/codeisland.js", - identifier: "codeisland" + pluginRef: "file:///tmp/uniisland.js", + identifier: "uniisland" ) ) let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(merged.utf8)) as? [String: Any]) @@ -705,14 +705,14 @@ hooks: XCTAssertEqual(json["autoshare"] as? Bool, false) let plugins = try XCTUnwrap(json["plugin"] as? [String]) XCTAssertTrue(plugins.contains("file:///user/other-plugin.js")) - XCTAssertTrue(plugins.contains("file:///tmp/codeisland.js")) + XCTAssertTrue(plugins.contains("file:///tmp/uniisland.js")) } func testMergeOpencodePluginRefDeduplicatesOurOwnRefs() throws { let original = """ { "plugin": [ - "file:///old/codeisland.js", + "file:///old/uniisland.js", "file:///some/vibe-island.js", "file:///user/other.js" ] @@ -721,16 +721,16 @@ hooks: let merged = try XCTUnwrap( ConfigInstaller.mergeOpencodePluginRef( originalContents: original, - pluginRef: "file:///new/codeisland.js", - identifier: "codeisland" + pluginRef: "file:///new/uniisland.js", + identifier: "uniisland" ) ) let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(merged.utf8)) as? [String: Any]) let plugins = try XCTUnwrap(json["plugin"] as? [String]) - XCTAssertEqual(plugins.filter { $0.contains("codeisland") }.count, 1) + XCTAssertEqual(plugins.filter { $0.contains("uniisland") }.count, 1) XCTAssertFalse(plugins.contains { $0.contains("vibe-island") }) XCTAssertTrue(plugins.contains("file:///user/other.js")) - XCTAssertTrue(plugins.contains("file:///new/codeisland.js")) + XCTAssertTrue(plugins.contains("file:///new/uniisland.js")) } func testMergeOpencodePluginRefReturnsNilOnMalformedJSON() { @@ -740,8 +740,8 @@ hooks: XCTAssertNil( ConfigInstaller.mergeOpencodePluginRef( originalContents: malformed, - pluginRef: "file:///tmp/codeisland.js", - identifier: "codeisland" + pluginRef: "file:///tmp/uniisland.js", + identifier: "uniisland" ) ) } @@ -752,8 +752,8 @@ hooks: XCTAssertNil( ConfigInstaller.mergeOpencodePluginRef( originalContents: array, - pluginRef: "file:///tmp/codeisland.js", - identifier: "codeisland" + pluginRef: "file:///tmp/uniisland.js", + identifier: "uniisland" ) ) } @@ -762,13 +762,13 @@ hooks: let original = """ { "model": "sonnet", - "plugin": ["file:///tmp/codeisland.js", "file:///user/other.js"] + "plugin": ["file:///tmp/uniisland.js", "file:///user/other.js"] } """ let cleaned = try XCTUnwrap( ConfigInstaller.removeOpencodePluginRef( originalContents: original, - identifier: "codeisland" + identifier: "uniisland" ) ) let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(cleaned.utf8)) as? [String: Any]) @@ -780,7 +780,7 @@ hooks: XCTAssertNil( ConfigInstaller.removeOpencodePluginRef( originalContents: "{ not valid json", - identifier: "codeisland" + identifier: "uniisland" ) ) } @@ -789,7 +789,7 @@ hooks: XCTAssertNil( ConfigInstaller.removeOpencodePluginRef( originalContents: nil, - identifier: "codeisland" + identifier: "uniisland" ) ) } @@ -811,8 +811,8 @@ hooks: let merged = try XCTUnwrap( ConfigInstaller.mergeOpencodePluginRef( originalContents: original, - pluginRef: "file:///tmp/codeisland.js", - identifier: "codeisland" + pluginRef: "file:///tmp/uniisland.js", + identifier: "uniisland" ) ) // Comment survives. @@ -826,7 +826,7 @@ hooks: XCTAssertTrue(modelIdx.lowerBound < permIdx.lowerBound) XCTAssertTrue(permIdx.lowerBound < pluginIdx.lowerBound) // New plugin ref added, old other-plugin kept. - XCTAssertTrue(merged.contains("file:///tmp/codeisland.js")) + XCTAssertTrue(merged.contains("file:///tmp/uniisland.js")) XCTAssertTrue(merged.contains("file:///old/other-plugin.js")) } @@ -845,22 +845,22 @@ hooks: let merged = try XCTUnwrap( ConfigInstaller.mergeOpencodePluginRef( originalContents: original, - pluginRef: "file:///tmp/codeisland.js", - identifier: "codeisland" + pluginRef: "file:///tmp/uniisland.js", + identifier: "uniisland" ) ) XCTAssertTrue(merged.contains("\"ANTHROPIC_API_KEY\": \"sk-super-secret\""), "User's API key must survive the install") XCTAssertTrue(merged.contains("\"MAX_MCP_OUTPUT_TOKENS\": \"200000\"")) XCTAssertTrue(merged.contains("\"autoMemoryEnabled\": false")) - XCTAssertTrue(merged.contains("file:///tmp/codeisland.js")) + XCTAssertTrue(merged.contains("file:///tmp/uniisland.js")) } func testRemoveOpencodePluginRefPreservesOriginalFormatting() throws { let original = """ { "model": "sonnet", - "plugin": ["file:///tmp/codeisland.js", "file:///user/other.js"], + "plugin": ["file:///tmp/uniisland.js", "file:///user/other.js"], "autoshare": false } @@ -868,12 +868,12 @@ hooks: let cleaned = try XCTUnwrap( ConfigInstaller.removeOpencodePluginRef( originalContents: original, - identifier: "codeisland" + identifier: "uniisland" ) ) XCTAssertTrue(cleaned.contains("\"model\": \"sonnet\"")) XCTAssertTrue(cleaned.contains("file:///user/other.js")) - XCTAssertFalse(cleaned.contains("file:///tmp/codeisland.js")) + XCTAssertFalse(cleaned.contains("file:///tmp/uniisland.js")) XCTAssertFalse(cleaned.contains("\\/"), "No slash escaping") } // MARK: - pi extension @@ -883,7 +883,7 @@ hooks: let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let piAgentDir = tempDir.appendingPathComponent(".pi/agent") let piExtensionDir = piAgentDir.appendingPathComponent("extensions") - let piExtensionPath = piExtensionDir.appendingPathComponent("codeisland.ts") + let piExtensionPath = piExtensionDir.appendingPathComponent("uniisland.ts") try fm.createDirectory(at: piAgentDir, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tempDir) } @@ -895,7 +895,7 @@ hooks: )) let contents = try String(contentsOf: piExtensionPath) - XCTAssertTrue(contents.contains("CodeIsland pi extension")) + XCTAssertTrue(contents.contains("UniIsland pi extension")) XCTAssertTrue(contents.contains("// version: v1")) XCTAssertTrue(contents.contains("@earendil-works/pi-coding-agent")) XCTAssertTrue(ConfigInstaller.isPiExtensionInstalled(piExtensionPath: piExtensionPath.path, fm: fm)) @@ -906,7 +906,7 @@ hooks: let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let piAgentDir = tempDir.appendingPathComponent(".pi/agent") let piExtensionDir = piAgentDir.appendingPathComponent("extensions") - let piExtensionPath = piExtensionDir.appendingPathComponent("codeisland.ts") + let piExtensionPath = piExtensionDir.appendingPathComponent("uniisland.ts") defer { try? fm.removeItem(at: tempDir) } XCTAssertTrue(ConfigInstaller.installPiExtension( @@ -918,14 +918,14 @@ hooks: XCTAssertFalse(fm.fileExists(atPath: piExtensionPath.path)) } - func testUninstallPiExtensionOnlyRemovesCodeIslandExtension() throws { + func testUninstallPiExtensionOnlyRemovesUniIslandExtension() throws { let fm = FileManager.default let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let piExtensionPath = tempDir.appendingPathComponent("codeisland.ts") + let piExtensionPath = tempDir.appendingPathComponent("uniisland.ts") let userExtensionPath = tempDir.appendingPathComponent("user.ts") try fm.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tempDir) } - try "// CodeIsland pi extension\n// version: v1\n".write(to: piExtensionPath, atomically: true, encoding: .utf8) + try "// UniIsland pi extension\n// version: v1\n".write(to: piExtensionPath, atomically: true, encoding: .utf8) try "// user extension\n".write(to: userExtensionPath, atomically: true, encoding: .utf8) ConfigInstaller.uninstallPiExtension(piExtensionPath: piExtensionPath.path, fm: fm) @@ -937,7 +937,7 @@ hooks: func testUninstallPiExtensionPreservesUserFileAtSamePath() throws { let fm = FileManager.default let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let piExtensionPath = tempDir.appendingPathComponent("codeisland.ts") + let piExtensionPath = tempDir.appendingPathComponent("uniisland.ts") try fm.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tempDir) } try "// user-managed file\n".write(to: piExtensionPath, atomically: true, encoding: .utf8) @@ -951,10 +951,10 @@ hooks: func testOutdatedPiExtensionRequiresRepair() throws { let fm = FileManager.default let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let piExtensionPath = tempDir.appendingPathComponent("codeisland.ts") + let piExtensionPath = tempDir.appendingPathComponent("uniisland.ts") try fm.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tempDir) } - try "// CodeIsland pi extension\n// version: old\n".write(to: piExtensionPath, atomically: true, encoding: .utf8) + try "// UniIsland pi extension\n// version: old\n".write(to: piExtensionPath, atomically: true, encoding: .utf8) XCTAssertFalse(ConfigInstaller.isPiExtensionInstalled(piExtensionPath: piExtensionPath.path, fm: fm)) } @@ -964,7 +964,7 @@ hooks: let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let ompAgentDir = tempDir.appendingPathComponent(".omp/agent") let ompExtensionDir = ompAgentDir.appendingPathComponent("extensions") - let ompExtensionPath = ompExtensionDir.appendingPathComponent("codeisland.ts") + let ompExtensionPath = ompExtensionDir.appendingPathComponent("uniisland.ts") try fm.createDirectory(at: ompAgentDir, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tempDir) } @@ -976,7 +976,7 @@ hooks: )) let contents = try String(contentsOf: ompExtensionPath) - XCTAssertTrue(contents.contains("CodeIsland pi extension")) + XCTAssertTrue(contents.contains("UniIsland pi extension")) XCTAssertTrue(contents.contains("// version: v1")) XCTAssertTrue(contents.contains("@oh-my-pi/pi-coding-agent")) XCTAssertFalse(contents.contains("@earendil-works/pi-coding-agent")) @@ -988,7 +988,7 @@ hooks: let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let ompAgentDir = tempDir.appendingPathComponent(".omp/agent") let ompExtensionDir = ompAgentDir.appendingPathComponent("extensions") - let ompExtensionPath = ompExtensionDir.appendingPathComponent("codeisland.ts") + let ompExtensionPath = ompExtensionDir.appendingPathComponent("uniisland.ts") defer { try? fm.removeItem(at: tempDir) } XCTAssertTrue(ConfigInstaller.installOmpExtension( @@ -1003,7 +1003,7 @@ hooks: func testUninstallOmpExtensionPreservesUserFileAtSamePath() throws { let fm = FileManager.default let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let ompExtensionPath = tempDir.appendingPathComponent("codeisland.ts") + let ompExtensionPath = tempDir.appendingPathComponent("uniisland.ts") try fm.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tempDir) } try "// user-managed file\n".write(to: ompExtensionPath, atomically: true, encoding: .utf8) diff --git a/Tests/CodeIslandTests/ESP32BridgeManagerQueueTests.swift b/Tests/UniIslandTests/ESP32BridgeManagerQueueTests.swift similarity index 98% rename from Tests/CodeIslandTests/ESP32BridgeManagerQueueTests.swift rename to Tests/UniIslandTests/ESP32BridgeManagerQueueTests.swift index 0631e54d..ba930838 100644 --- a/Tests/CodeIslandTests/ESP32BridgeManagerQueueTests.swift +++ b/Tests/UniIslandTests/ESP32BridgeManagerQueueTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland final class ESP32BridgeManagerQueueTests: XCTestCase { func testWriteQueueDropsOldAuxiliaryFramesBeforePrimaryFrames() { diff --git a/Tests/CodeIslandTests/HookRepairTests.swift b/Tests/UniIslandTests/HookRepairTests.swift similarity index 90% rename from Tests/CodeIslandTests/HookRepairTests.swift rename to Tests/UniIslandTests/HookRepairTests.swift index 98d962e9..54e79325 100644 --- a/Tests/CodeIslandTests/HookRepairTests.swift +++ b/Tests/UniIslandTests/HookRepairTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland /// Regression coverage for #182: verifyAndRepair must respect a user who /// deleted some hook events by hand instead of silently re-adding them. The @@ -14,7 +14,7 @@ final class HookRepairTests: XCTestCase { ] private func ourEntry() -> [String: Any] { - ["hooks": [["type": "command", "command": "~/.codeisland/codeisland-bridge"]]] + ["hooks": [["type": "command", "command": "~/.uniisland/uniisland-bridge"]]] } private func foreignEntry() -> [String: Any] { @@ -50,7 +50,7 @@ final class HookRepairTests: XCTestCase { func testStaleAsyncEntryForcesRepair() { // Our hook present but carries a legacy "async" key → must be rewritten. let staleEntry: [String: Any] = [ - "hooks": [["type": "command", "command": "~/.codeisland/codeisland-bridge", "async": true]] + "hooks": [["type": "command", "command": "~/.uniisland/uniisland-bridge", "async": true]] ] let hooks: [String: Any] = ["PreToolUse": [staleEntry]] XCTAssertFalse(ConfigInstaller.shouldPreservePartialHooks(hooks: hooks, events: events)) diff --git a/Tests/CodeIslandTests/HookServerCwdFilterTests.swift b/Tests/UniIslandTests/HookServerCwdFilterTests.swift similarity index 98% rename from Tests/CodeIslandTests/HookServerCwdFilterTests.swift rename to Tests/UniIslandTests/HookServerCwdFilterTests.swift index c7542c74..4cef437d 100644 --- a/Tests/CodeIslandTests/HookServerCwdFilterTests.swift +++ b/Tests/UniIslandTests/HookServerCwdFilterTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland /// Coverage for the cwd-substring blocklist that powers Settings → Behavior → /// "Ignore Hooks From Paths" (#125). The matcher itself is a tiny pure function diff --git a/Tests/CodeIslandTests/JSONMinimalEditorTests.swift b/Tests/UniIslandTests/JSONMinimalEditorTests.swift similarity index 94% rename from Tests/CodeIslandTests/JSONMinimalEditorTests.swift rename to Tests/UniIslandTests/JSONMinimalEditorTests.swift index cefbafb6..e92f5bcc 100644 --- a/Tests/CodeIslandTests/JSONMinimalEditorTests.swift +++ b/Tests/UniIslandTests/JSONMinimalEditorTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland final class JSONMinimalEditorTests: XCTestCase { @@ -20,7 +20,7 @@ final class JSONMinimalEditorTests: XCTestCase { """ let newHooks: [String: Any] = [ "PreToolUse": [ - ["matcher": "", "hooks": [["type": "command", "command": "~/.codeisland/codeisland-hook.sh", "timeout": 5]]] + ["matcher": "", "hooks": [["type": "command", "command": "~/.uniisland/uniisland-hook.sh", "timeout": 5]]] ] ] let result = try XCTUnwrap(JSONMinimalEditor.setTopLevelValue(in: original, key: "hooks", value: newHooks)) @@ -40,7 +40,7 @@ final class JSONMinimalEditorTests: XCTestCase { XCTAssertTrue(hooksIdx.lowerBound < autoIdx.lowerBound) // New hooks payload is present. - XCTAssertTrue(result.contains("~/.codeisland/codeisland-hook.sh")) + XCTAssertTrue(result.contains("~/.uniisland/uniisland-hook.sh")) // Result is still valid JSON. let parsed = try JSONSerialization.jsonObject(with: Data(result.utf8)) as? [String: Any] @@ -172,15 +172,15 @@ final class JSONMinimalEditorTests: XCTestCase { let original = """ { "model": "sonnet", - "plugin": ["file:///old/codeisland.js"], + "plugin": ["file:///old/uniisland.js"], "autoshare": false } """ - let result = try XCTUnwrap(JSONMinimalEditor.setTopLevelValue(in: original, key: "plugin", value: ["file:///new/codeisland.js"])) + let result = try XCTUnwrap(JSONMinimalEditor.setTopLevelValue(in: original, key: "plugin", value: ["file:///new/uniisland.js"])) XCTAssertTrue(result.contains("\"model\": \"sonnet\"")) XCTAssertTrue(result.contains("\"autoshare\": false")) - XCTAssertTrue(result.contains("file:///new/codeisland.js")) - XCTAssertFalse(result.contains("file:///old/codeisland.js")) + XCTAssertTrue(result.contains("file:///new/uniisland.js")) + XCTAssertFalse(result.contains("file:///old/uniisland.js")) XCTAssertFalse(result.contains("\\/")) } } diff --git a/Tests/CodeIslandTests/KiroSupportTests.swift b/Tests/UniIslandTests/KiroSupportTests.swift similarity index 98% rename from Tests/CodeIslandTests/KiroSupportTests.swift rename to Tests/UniIslandTests/KiroSupportTests.swift index ee7dcba0..61478c9f 100644 --- a/Tests/CodeIslandTests/KiroSupportTests.swift +++ b/Tests/UniIslandTests/KiroSupportTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import CodeIsland -import CodeIslandCore +@testable import UniIsland +import UniIslandCore /// Locks in the wire-level pieces of Kiro CLI support (#127) — the parts that /// don't need a live Kiro install to verify, but where a typo would silently diff --git a/Tests/CodeIslandTests/L10nTests.swift b/Tests/UniIslandTests/L10nTests.swift similarity index 87% rename from Tests/CodeIslandTests/L10nTests.swift rename to Tests/UniIslandTests/L10nTests.swift index 03158440..0a2497d1 100644 --- a/Tests/CodeIslandTests/L10nTests.swift +++ b/Tests/UniIslandTests/L10nTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland final class L10nTests: XCTestCase { override func setUp() { @@ -41,7 +41,7 @@ final class L10nTests: XCTestCase { XCTAssertEqual(L10n.shared["behavior"], "Davranış") XCTAssertEqual(L10n.shared["appearance"], "Görünüm") XCTAssertEqual(L10n.shared["language"], "Dil") - XCTAssertEqual(L10n.shared["settings_title"], "CodeIsland Ayarları") + XCTAssertEqual(L10n.shared["settings_title"], "UniIsland Ayarları") XCTAssertEqual(L10n.shared["quit"], "Çık") } @@ -52,7 +52,7 @@ final class L10nTests: XCTestCase { XCTAssertEqual(L10n.shared["behavior"], "동작") XCTAssertEqual(L10n.shared["appearance"], "외관") XCTAssertEqual(L10n.shared["language"], "언어") - XCTAssertEqual(L10n.shared["settings_title"], "CodeIsland 설정") + XCTAssertEqual(L10n.shared["settings_title"], "UniIsland 설정") XCTAssertEqual(L10n.shared["quit"], "종료") } @@ -63,7 +63,7 @@ final class L10nTests: XCTestCase { XCTAssertEqual(L10n.shared["behavior"], "動作") XCTAssertEqual(L10n.shared["appearance"], "外観") XCTAssertEqual(L10n.shared["language"], "言語") - XCTAssertEqual(L10n.shared["settings_title"], "CodeIsland 設定") + XCTAssertEqual(L10n.shared["settings_title"], "UniIsland 設定") XCTAssertEqual(L10n.shared["quit"], "終了") } @@ -100,7 +100,7 @@ final class L10nTests: XCTestCase { let updateAvailable = L10n.shared["update_available_body"] let formattedUpdate = String(format: updateAvailable, "1.0.19", "1.0.18") - XCTAssertEqual(formattedUpdate, "CodeIsland 1.0.19 mevcut (şimdiki: 1.0.18). İndirmek ister misiniz?") + XCTAssertEqual(formattedUpdate, "UniIsland 1.0.19 mevcut (şimdiki: 1.0.18). İndirmek ister misiniz?") } func testKoreanNumericPlaceholdersWork() { @@ -112,7 +112,7 @@ final class L10nTests: XCTestCase { let updateAvailable = L10n.shared["update_available_body"] let formattedUpdate = String(format: updateAvailable, "1.0.19", "1.0.18") - XCTAssertEqual(formattedUpdate, "CodeIsland 1.0.19 버전을 사용할 수 있습니다(현재: 1.0.18). 다운로드하시겠습니까?") + XCTAssertEqual(formattedUpdate, "UniIsland 1.0.19 버전을 사용할 수 있습니다(현재: 1.0.18). 다운로드하시겠습니까?") } func testJapaneseNumericPlaceholdersWork() { @@ -124,6 +124,6 @@ final class L10nTests: XCTestCase { let updateAvailable = L10n.shared["update_available_body"] let formattedUpdate = String(format: updateAvailable, "1.0.19", "1.0.18") - XCTAssertEqual(formattedUpdate, "CodeIsland 1.0.19 が利用可能です (現在: 1.0.18)。ダウンロードしますか?") + XCTAssertEqual(formattedUpdate, "UniIsland 1.0.19 が利用可能です (現在: 1.0.18)。ダウンロードしますか?") } } diff --git a/Tests/UniIslandTests/NotchPanelViewTests.swift b/Tests/UniIslandTests/NotchPanelViewTests.swift new file mode 100644 index 00000000..42b9e58a --- /dev/null +++ b/Tests/UniIslandTests/NotchPanelViewTests.swift @@ -0,0 +1,342 @@ +import XCTest +@testable import UniIsland + +final class NotchPanelViewTests: XCTestCase { + func testCollapsedWidthScaleUsesSinglePercentIncrements() { + XCTAssertEqual(NotchWidthScale.step, 1) + } + + func testEffectiveNotchWidthAppliesCollapsedWidthScale() { + XCTAssertEqual( + NotchWidthMetrics.effectiveNotchWidth(notchW: 200, collapsedWidthScale: 50), + 100, + accuracy: 0.001 + ) + XCTAssertEqual( + NotchWidthMetrics.effectiveNotchWidth(notchW: 200, collapsedWidthScale: 150), + 300, + accuracy: 0.001 + ) + } + + func testEffectiveNotchWidthClampsOutOfRangeScale() { + XCTAssertEqual( + NotchWidthMetrics.effectiveNotchWidth(notchW: 200, collapsedWidthScale: 10), + 100, + accuracy: 0.001 + ) + XCTAssertEqual( + NotchWidthMetrics.effectiveNotchWidth(notchW: 200, collapsedWidthScale: 250), + 300, + accuracy: 0.001 + ) + } + + func testIdleExpandedWidthFollowsCollapsedWidthScaleButStaysPartial() { + let normal = NotchWidthMetrics.idlePanelWidth( + notchW: 200, + compactWingWidth: 30, + collapsedWidthScale: 100, + phase: .expanded + ) + let wider = NotchWidthMetrics.idlePanelWidth( + notchW: 200, + compactWingWidth: 30, + collapsedWidthScale: 150, + phase: .expanded + ) + let fullExpanded = NotchWidthMetrics.expandedPanelWidth( + notchW: 200, + collapsedWidthScale: 150, + screenWidth: 800 + ) + + XCTAssertGreaterThan(wider, normal) + XCTAssertLessThan(wider, fullExpanded) + } + + func testCollapsedRightWingReservesRoomForAlertCount() { + let normal = NotchWidthMetrics.collapsedRightWingReservedWidth( + showToolStatus: false, + isAlerting: false + ) + let alerting = NotchWidthMetrics.collapsedRightWingReservedWidth( + showToolStatus: false, + isAlerting: true + ) + + XCTAssertGreaterThanOrEqual(normal, 30) + XCTAssertGreaterThan(alerting, normal) + } + + func testActiveCollapsedPanelWidthAddsRequestedBreathingRoom() { + let width = NotchWidthMetrics.activeCollapsedPanelWidth( + scaledCenterGap: 198, + compactWingWidth: 35, + rightWingWidth: 32, + prehoverExtra: 0, + minimumVisibleWidth: 198 + ) + + XCTAssertEqual(width, 198 + 35 + 32 + 60, accuracy: 0.001) + } + + func testActiveCollapsedPanelWidthUsesScaledWidthWhenStillCoveringNotch() { + let width = NotchWidthMetrics.activeCollapsedPanelWidth( + scaledCenterGap: 126, + compactWingWidth: 35, + rightWingWidth: 32, + prehoverExtra: 0, + minimumVisibleWidth: 198 + ) + + XCTAssertEqual(width, 126 + 35 + 32 + 60, accuracy: 0.001) + } + + func testActiveCollapsedPanelWidthNeverShrinksBelowNotchCoverage() { + let width = NotchWidthMetrics.activeCollapsedPanelWidth( + scaledCenterGap: 80, + compactWingWidth: 20, + rightWingWidth: 20, + prehoverExtra: 0, + minimumVisibleWidth: 198 + ) + + XCTAssertEqual(width, 198, accuracy: 0.001) + } + + func testCollapsedCenterGapUsesPhysicalNotchWhenScaledSmaller() { + XCTAssertEqual( + NotchWidthMetrics.collapsedCenterGap( + effectiveNotchWidth: 100, + physicalNotchWidth: 200, + hasNotch: true + ), + 198, + accuracy: 0.001 + ) + } + + func testCollapsedCenterGapUsesEffectiveWidthWithoutNotch() { + XCTAssertEqual( + NotchWidthMetrics.collapsedCenterGap( + effectiveNotchWidth: 100, + physicalNotchWidth: 200, + hasNotch: false + ), + 100, + accuracy: 0.001 + ) + } + + func testHoverInteractionCancelsExpansionWhenMouseLeavesPrehover() { + var phase = NotchHoverInteraction.nextPhase(from: .collapsed, event: .mouseEntered) + XCTAssertEqual(phase, .prehover) + + phase = NotchHoverInteraction.nextPhase(from: phase, event: .mouseExited) + XCTAssertEqual(phase, .collapsed) + + phase = NotchHoverInteraction.nextPhase(from: phase, event: .expandDelayElapsed) + XCTAssertEqual(phase, .collapsed) + } + + func testHoverInteractionKeepsExpandedUntilCollapseDelayElapsed() { + var phase = NotchHoverInteraction.nextPhase(from: .collapsed, event: .mouseEntered) + phase = NotchHoverInteraction.nextPhase(from: phase, event: .expandDelayElapsed) + XCTAssertEqual(phase, .expanded) + + phase = NotchHoverInteraction.nextPhase(from: phase, event: .mouseExited) + XCTAssertEqual(phase, .expanded) + + phase = NotchHoverInteraction.nextPhase(from: phase, event: .collapseDelayElapsed) + XCTAssertEqual(phase, .collapsed) + } + + func testShouldTriggerJumpFailureFeedbackWhenAllAttemptsFail() { + XCTAssertTrue(shouldTriggerJumpFailureFeedback([false, false, false])) + } + + func testShouldNotTriggerJumpFailureFeedbackWhenAnyAttemptSucceeds() { + XCTAssertFalse(shouldTriggerJumpFailureFeedback([false, true, false])) + } + + func testJumpFailureShakeSequenceUsesFastAlternatingOffsets() { + XCTAssertEqual(JumpAnimationHelper.shakeSequence, [8, -8, 6, -6, 3, -3, 0]) + } + + func testWeChatNativeActivationSkipsWorkspaceTitleMatching() { + XCTAssertTrue( + TerminalActivator.shouldUseDirectNativeAppActivation( + source: "wechat", + bundleId: "com.tencent.xinWeChat", + cwd: "/Applications/WeChat.app" + ) + ) + } + + func testProjectNativeActivationKeepsWorkspaceTitleMatching() { + XCTAssertFalse( + TerminalActivator.shouldUseDirectNativeAppActivation( + source: "cursor", + bundleId: "com.todesktop.230313mzl4w4u92", + cwd: "/Users/example/project" + ) + ) + } + + func testEvaluateJumpValidationReturnsSuccessWhenCheckSucceeds() async { + var callCount = 0 + let outcome = await evaluateJumpValidation( + delays: [1, 1, 1], + isCancelled: { false }, + sleep: { _ in }, + checkSucceeded: { + callCount += 1 + return callCount == 2 + } + ) + + XCTAssertEqual(outcome, .success) + } + + func testEvaluateJumpValidationReturnsFailedWhenAllChecksFail() async { + let outcome = await evaluateJumpValidation( + delays: [1, 1, 1], + isCancelled: { false }, + sleep: { _ in }, + checkSucceeded: { false } + ) + + XCTAssertEqual(outcome, .failed) + } + + func testEvaluateJumpValidationReturnsCancelledBeforeCheckRuns() async { + var checksRan = 0 + let outcome = await evaluateJumpValidation( + delays: [1, 1, 1], + isCancelled: { true }, + sleep: { _ in }, + checkSucceeded: { + checksRan += 1 + return false + } + ) + + XCTAssertEqual(outcome, .cancelled) + XCTAssertEqual(checksRan, 0) + } + + func testClickJumpCollapseTimelineShowsClickRingWhenCursorReachesClickPoint() { + let timeline = clickJumpCollapsePreviewTimeline(progress: 0.26) + + XCTAssertGreaterThan(timeline.expand, 0.95) + XCTAssertTrue(timeline.showClickRing) + XCTAssertEqual(timeline.cursorX, 0, accuracy: 0.001) + XCTAssertEqual(timeline.cursorY, 0, accuracy: 0.001) + } + + func testClickJumpCollapseTimelineMovesCursorToClickPointFaster() { + let timeline = clickJumpCollapsePreviewTimeline(progress: 0.08) + + XCTAssertEqual(timeline.cursorX, 0, accuracy: 0.001) + XCTAssertEqual(timeline.cursorY, 0, accuracy: 0.001) + } + + func testClickJumpCollapseTimelineMovesCursorFullyOffscreenBeforeExpandStarts() { + let timeline = clickJumpCollapsePreviewTimeline(progress: 0.80) + + XCTAssertEqual(timeline.cursorX, 34, accuracy: 0.001) + XCTAssertEqual(timeline.cursorY, 28, accuracy: 0.001) + XCTAssertLessThanOrEqual(timeline.expand, 0.001) + } + + func testClickJumpCollapseTimelineStartsExpandAfterCursorIsAlreadyOffscreen() { + let timeline = clickJumpCollapsePreviewTimeline(progress: 0.85) + + XCTAssertGreaterThan(timeline.expand, 0.3) + XCTAssertEqual(timeline.cursorX, 34, accuracy: 0.001) + XCTAssertEqual(timeline.cursorY, 28, accuracy: 0.001) + } + + func testClickJumpCollapseTimelineUsesMouseLeaveLikeCollapseSpeed() { + let timeline = clickJumpCollapsePreviewTimeline(progress: 0.38) + + XCTAssertGreaterThan(timeline.expand, 0.5) + XCTAssertLessThan(timeline.expand, 0.7) + } + + func testClickJumpCollapseTimelineUsesMouseLeaveLikeExpandSpeed() { + let timeline = clickJumpCollapsePreviewTimeline(progress: 0.93) + + XCTAssertGreaterThanOrEqual(timeline.expand, 0.999) + } + + func testClickJumpCollapseTimelineHoldsCollapsedStateForMiddleWindow() { + let timeline = clickJumpCollapsePreviewTimeline(progress: 0.60) + + XCTAssertLessThanOrEqual(timeline.expand, 0.001) + XCTAssertEqual(timeline.cursorX, 0, accuracy: 0.001) + XCTAssertEqual(timeline.cursorY, 0, accuracy: 0.001) + } + + func testClickJumpCollapseTimelineLoopSeamIsSmooth() { + let start = clickJumpCollapsePreviewTimeline(progress: 0) + let end = clickJumpCollapsePreviewTimeline(progress: 1) + + XCTAssertEqual(start.expand, end.expand, accuracy: 0.001) + XCTAssertEqual(start.cursorX, end.cursorX, accuracy: 0.001) + XCTAssertEqual(start.cursorY, end.cursorY, accuracy: 0.001) + } + + func testClickJumpCollapseTimelineLowersClickPoint() { + let timeline = clickJumpCollapsePreviewTimeline(progress: 0.26) + XCTAssertEqual(timeline.clickPointY, 16.0, accuracy: 0.1) + } + + func testWeChatSummaryShowsTwoSendersVertically() { + let summary = AppState.summarizeWeChatNotifications([ + AppState.WeChatNotif(sender: "张三", body: "在吗"), + AppState.WeChatNotif(sender: "李四", body: "收到"), + ], badge: "2") + + XCTAssertEqual(summary, "张三: 在吗\n李四: 收到") + } + + func testWeChatSummaryDropsBodiesWhenManySendersArrive() { + let summary = AppState.summarizeWeChatNotifications([ + AppState.WeChatNotif(sender: "张三", body: "1"), + AppState.WeChatNotif(sender: "李四", body: "2"), + AppState.WeChatNotif(sender: "王五", body: "3"), + AppState.WeChatNotif(sender: "赵六", body: "4"), + AppState.WeChatNotif(sender: "钱七", body: "5"), + ], badge: "5") + + XCTAssertEqual(summary, "张三 发来消息\n李四 发来消息\n王五 发来消息\n赵六 发来消息\n等 5 人发来消息") + } + + func testWeChatSummaryOnlyUsesUnreadBadgeCount() { + let summary = AppState.summarizeWeChatNotifications([ + AppState.WeChatNotif(sender: "新联系人", body: "刚发的"), + AppState.WeChatNotif(sender: "旧联系人", body: "已读旧消息"), + ], badge: "1") + + XCTAssertEqual(summary, "新联系人: 刚发的") + } + + func testWeChatPollIntervalBurstsAfterChange() { + XCTAssertEqual( + AppState.weChatPollInterval(isBursting: true, burstUntil: Date(timeIntervalSinceReferenceDate: 10), now: Date(timeIntervalSinceReferenceDate: 5)), + 0.03, + accuracy: 0.001 + ) + } + + func testWeChatPollIntervalReturnsToIdleAfterBurst() { + XCTAssertEqual( + AppState.weChatPollInterval(isBursting: true, burstUntil: Date(timeIntervalSinceReferenceDate: 5), now: Date(timeIntervalSinceReferenceDate: 10)), + 0.2, + accuracy: 0.001 + ) + } + +} diff --git a/Tests/CodeIslandTests/PanelWindowControllerTests.swift b/Tests/UniIslandTests/PanelWindowControllerTests.swift similarity index 97% rename from Tests/CodeIslandTests/PanelWindowControllerTests.swift rename to Tests/UniIslandTests/PanelWindowControllerTests.swift index b15dae01..a1ff557d 100644 --- a/Tests/CodeIslandTests/PanelWindowControllerTests.swift +++ b/Tests/UniIslandTests/PanelWindowControllerTests.swift @@ -1,6 +1,6 @@ import AppKit import XCTest -@testable import CodeIsland +@testable import UniIsland final class PanelWindowControllerTests: XCTestCase { func testScreenHopMotionUsesMoreVisibleTiming() { diff --git a/Tests/CodeIslandTests/RemoteManagerTests.swift b/Tests/UniIslandTests/RemoteManagerTests.swift similarity index 96% rename from Tests/CodeIslandTests/RemoteManagerTests.swift rename to Tests/UniIslandTests/RemoteManagerTests.swift index 267ac532..57dcf25b 100644 --- a/Tests/CodeIslandTests/RemoteManagerTests.swift +++ b/Tests/UniIslandTests/RemoteManagerTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland @MainActor final class RemoteManagerTests: XCTestCase { diff --git a/Tests/CodeIslandTests/SSHForwarderTests.swift b/Tests/UniIslandTests/SSHForwarderTests.swift similarity index 87% rename from Tests/CodeIslandTests/SSHForwarderTests.swift rename to Tests/UniIslandTests/SSHForwarderTests.swift index 900ea5ef..02de0bf5 100644 --- a/Tests/CodeIslandTests/SSHForwarderTests.swift +++ b/Tests/UniIslandTests/SSHForwarderTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland @MainActor final class SSHForwarderTests: XCTestCase { @@ -8,12 +8,12 @@ final class SSHForwarderTests: XCTestCase { func testCleanupArgumentsBasic() { let host = RemoteHost(name: "test", host: "192.168.1.10", user: "alice") - let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/codeisland-501.sock") + let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/uniisland-501.sock") XCTAssertEqual(args, [ "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", - "alice@192.168.1.10", "rm", "-f", "/tmp/codeisland-501.sock", + "alice@192.168.1.10", "rm", "-f", "/tmp/uniisland-501.sock", ]) } @@ -24,7 +24,7 @@ final class SSHForwarderTests: XCTestCase { user: "alice", identityFile: "~/.ssh/id_ed25519" ) - let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/codeisland-501.sock") + let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/uniisland-501.sock") XCTAssertTrue(args.contains("-i")) XCTAssertTrue(args.contains("~/.ssh/id_ed25519")) @@ -36,7 +36,7 @@ final class SSHForwarderTests: XCTestCase { func testCleanupArgumentsIncludesPort() { let host = RemoteHost(name: "test", host: "192.168.1.10", user: "alice", port: 2222) - let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/codeisland-501.sock") + let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/uniisland-501.sock") XCTAssertTrue(args.contains("-p")) XCTAssertTrue(args.contains("2222")) @@ -50,7 +50,7 @@ final class SSHForwarderTests: XCTestCase { port: 2222, identityFile: "/Users/bob/.ssh/id_rsa" ) - let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/codeisland-1000.sock") + let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/uniisland-1000.sock") XCTAssertTrue(args.contains("-p")) XCTAssertTrue(args.contains("2222")) @@ -58,7 +58,7 @@ final class SSHForwarderTests: XCTestCase { XCTAssertTrue(args.contains("/Users/bob/.ssh/id_rsa")) // Last three elements are always: target, "rm", "-f", socketPath let suffix = args.suffix(4) - XCTAssertEqual(Array(suffix), ["bob@example.com", "rm", "-f", "/tmp/codeisland-1000.sock"]) + XCTAssertEqual(Array(suffix), ["bob@example.com", "rm", "-f", "/tmp/uniisland-1000.sock"]) } func testCleanupArgumentsTrimsIdentityWhitespace() { @@ -68,7 +68,7 @@ final class SSHForwarderTests: XCTestCase { user: "alice", identityFile: " ~/.ssh/id_ed25519 " ) - let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/codeisland-501.sock") + let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/uniisland-501.sock") // Should include -i with trimmed value let iIndex = args.firstIndex(of: "-i")! @@ -77,21 +77,21 @@ final class SSHForwarderTests: XCTestCase { func testCleanupArgumentsEmptyIdentityOmitted() { let host = RemoteHost(name: "test", host: "192.168.1.10", user: "alice", identityFile: "") - let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/codeisland-501.sock") + let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/uniisland-501.sock") XCTAssertFalse(args.contains("-i")) } func testCleanupArgumentsEmptyPortOmitted() { let host = RemoteHost(name: "test", host: "192.168.1.10", user: "alice") - let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/codeisland-501.sock") + let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/uniisland-501.sock") XCTAssertFalse(args.contains("-p")) } func testCleanupArgumentsAlwaysIncludesRm() { let host = RemoteHost(name: "test", host: "10.0.0.1", user: "root") - let socketPath = "/tmp/codeisland-0.sock" + let socketPath = "/tmp/uniisland-0.sock" let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: socketPath) // Must contain: rm -f @@ -102,7 +102,7 @@ final class SSHForwarderTests: XCTestCase { func testCleanupArgumentsBatchModeAlwaysOn() { let host = RemoteHost(name: "test", host: "10.0.0.1", user: "root") - let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/codeisland-0.sock") + let args = SSHForwarder.cleanupArguments(host: host, remoteSocketPath: "/tmp/uniisland-0.sock") XCTAssertTrue(args.contains("BatchMode=yes")) XCTAssertTrue(args.contains("ConnectTimeout=5")) diff --git a/Tests/CodeIslandTests/ScreenDetectorTests.swift b/Tests/UniIslandTests/ScreenDetectorTests.swift similarity index 98% rename from Tests/CodeIslandTests/ScreenDetectorTests.swift rename to Tests/UniIslandTests/ScreenDetectorTests.swift index dfc09130..2f4e8795 100644 --- a/Tests/CodeIslandTests/ScreenDetectorTests.swift +++ b/Tests/UniIslandTests/ScreenDetectorTests.swift @@ -1,6 +1,6 @@ import AppKit import XCTest -@testable import CodeIsland +@testable import UniIsland final class ScreenDetectorTests: XCTestCase { func testAutoPreferredIndexUsesActiveWorkScreenBeforeBuiltInScreen() { diff --git a/Tests/CodeIslandTests/SessionPersistenceTests.swift b/Tests/UniIslandTests/SessionPersistenceTests.swift similarity index 99% rename from Tests/CodeIslandTests/SessionPersistenceTests.swift rename to Tests/UniIslandTests/SessionPersistenceTests.swift index b75402eb..4fb41c07 100644 --- a/Tests/CodeIslandTests/SessionPersistenceTests.swift +++ b/Tests/UniIslandTests/SessionPersistenceTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland final class SessionPersistenceTests: XCTestCase { func testPersistedSessionDecodesWithoutCliStartTimeForBackwardCompatibility() throws { diff --git a/Tests/CodeIslandTests/SessionTitleStoreTests.swift b/Tests/UniIslandTests/SessionTitleStoreTests.swift similarity index 97% rename from Tests/CodeIslandTests/SessionTitleStoreTests.swift rename to Tests/UniIslandTests/SessionTitleStoreTests.swift index f2a465c1..784ce894 100644 --- a/Tests/CodeIslandTests/SessionTitleStoreTests.swift +++ b/Tests/UniIslandTests/SessionTitleStoreTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland final class SessionTitleStoreTests: XCTestCase { func testCodexThreadNameLookupReturnsLatestMatchingTitle() throws { diff --git a/Tests/CodeIslandTests/ZellijPaneIdParseTests.swift b/Tests/UniIslandTests/ZellijPaneIdParseTests.swift similarity index 98% rename from Tests/CodeIslandTests/ZellijPaneIdParseTests.swift rename to Tests/UniIslandTests/ZellijPaneIdParseTests.swift index df7472f8..dac0a9d6 100644 --- a/Tests/CodeIslandTests/ZellijPaneIdParseTests.swift +++ b/Tests/UniIslandTests/ZellijPaneIdParseTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CodeIsland +@testable import UniIsland /// `ZELLIJ_PANE_ID` env shape varies across Zellij versions: bare integer, "terminal_N" /// prefix (Zellij's `PaneId::Display` impl), or "plugin_N" (plugin pane — agents don't diff --git a/CodeIsland.entitlements b/UniIsland.entitlements similarity index 100% rename from CodeIsland.entitlements rename to UniIsland.entitlements diff --git a/build.sh b/build.sh index aca2b8d4..58f2190e 100755 --- a/build.sh +++ b/build.sh @@ -6,7 +6,7 @@ if [ -d /Applications/Xcode.app/Contents/Developer ]; then export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer fi -APP_NAME="CodeIsland" +APP_NAME="UniIsland" BUILD_DIR=".build/release" APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" ICON_CATALOG="Assets.xcassets" @@ -86,8 +86,8 @@ build_mac() { lipo -create "$ARM_DIR/$APP_NAME" "$X86_DIR/$APP_NAME" \ -output "$APP_BUNDLE/Contents/MacOS/$APP_NAME" - lipo -create "$ARM_DIR/codeisland-bridge" "$X86_DIR/codeisland-bridge" \ - -output "$APP_BUNDLE/Contents/Helpers/codeisland-bridge" + lipo -create "$ARM_DIR/uniisland-bridge" "$X86_DIR/uniisland-bridge" \ + -output "$APP_BUNDLE/Contents/Helpers/uniisland-bridge" cp Info.plist "$APP_BUNDLE/Contents/Info.plist" echo "Embedding frameworks..." @@ -103,23 +103,27 @@ build_mac() { install_name_tool -add_rpath "@executable_path/../Frameworks" \ "$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true install_name_tool -add_rpath "@executable_path/../../Frameworks" \ - "$APP_BUNDLE/Contents/Helpers/codeisland-bridge" 2>/dev/null || true - - echo "Compiling app icon assets..." - xcrun actool \ - --output-format human-readable-text \ - --warnings \ - --errors \ - --notices \ - --platform macosx \ - --target-device mac \ - --minimum-deployment-target 14.0 \ - --app-icon AppIcon \ - --output-partial-info-plist "$ICON_INFO_PLIST" \ - --compile "$APP_BUNDLE/Contents/Resources" \ - "$ICON_CATALOG" \ - "$ICON_SOURCE" - cp "Sources/CodeIsland/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns" + "$APP_BUNDLE/Contents/Helpers/uniisland-bridge" 2>/dev/null || true + + if xcrun -f actool &>/dev/null; then + echo "Compiling app icon assets..." + xcrun actool \ + --output-format human-readable-text \ + --warnings \ + --errors \ + --notices \ + --platform macosx \ + --target-device mac \ + --minimum-deployment-target 14.0 \ + --app-icon AppIcon \ + --output-partial-info-plist "$ICON_INFO_PLIST" \ + --compile "$APP_BUNDLE/Contents/Resources" \ + "$ICON_CATALOG" \ + "$ICON_SOURCE" + else + echo "WARNING: actool not found, skipping app icon asset compilation." + fi + cp "Sources/UniIsland/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns" # Copy SPM resource bundles into Contents/Resources/ (required for code signing) for bundle in .build/*/release/*.bundle; do @@ -129,7 +133,7 @@ build_mac() { fi done - ENTITLEMENTS="CodeIsland.entitlements" + ENTITLEMENTS="UniIsland.entitlements" # Use SIGN_ID env var, or auto-detect: prefer "Developer ID Application" for distribution, # fall back to any valid identity, then ad-hoc @@ -139,29 +143,44 @@ build_mac() { if [ -z "$SIGN_ID" ]; then SIGN_ID=$(security find-identity -v -p codesigning | grep -v "REVOKED" | grep '"' | head -1 | sed 's/.*"\(.*\)".*/\1/' 2>/dev/null || true) fi + # Local dev fallback: a self-signed "UniIsland Dev" cert (untrusted, so omitted by -v). + # Signing with a stable identity keeps Accessibility / Full Disk Access grants across + # rebuilds — ad-hoc changes the cdhash every build and drops those TCC permissions. + if [ -z "$SIGN_ID" ] && security find-identity -p codesigning | grep -q "UniIsland Dev"; then + SIGN_ID="UniIsland Dev" + fi if [ -z "$SIGN_ID" ]; then echo "No developer certificate found, using ad-hoc signing..." SIGN_ID="-" fi + # Hardened runtime is only needed for notarization/distribution (Developer ID). For ad-hoc + # or the local self-signed "UniIsland Dev" cert it must be OFF — hardened runtime enforces + # library validation (matching Team IDs), which a self-signed cert can't satisfy and would + # crash the app at launch when loading Sparkle.framework. + SIGN_OPTS="--options runtime" + if [ "$SIGN_ID" = "-" ] || [ "$SIGN_ID" = "UniIsland Dev" ]; then + SIGN_OPTS="" + fi + echo "Code signing ($SIGN_ID)..." # Sign embedded frameworks first (inside-out). SPARKLE_FW="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework" # Sign nested helpers inside Sparkle before the framework itself. for xpc in "$SPARKLE_FW/Versions/B/XPCServices/"*.xpc; do [ -e "$xpc" ] || continue - codesign --force --options runtime --sign "$SIGN_ID" "$xpc" + codesign --force $SIGN_OPTS --sign "$SIGN_ID" "$xpc" done if [ -d "$SPARKLE_FW/Versions/B/Updater.app" ]; then - codesign --force --options runtime --sign "$SIGN_ID" "$SPARKLE_FW/Versions/B/Updater.app" + codesign --force $SIGN_OPTS --sign "$SIGN_ID" "$SPARKLE_FW/Versions/B/Updater.app" fi if [ -e "$SPARKLE_FW/Versions/B/Autoupdate" ]; then - codesign --force --options runtime --sign "$SIGN_ID" "$SPARKLE_FW/Versions/B/Autoupdate" + codesign --force $SIGN_OPTS --sign "$SIGN_ID" "$SPARKLE_FW/Versions/B/Autoupdate" fi - codesign --force --options runtime --sign "$SIGN_ID" "$SPARKLE_FW" + codesign --force $SIGN_OPTS --sign "$SIGN_ID" "$SPARKLE_FW" - codesign --force --options runtime --sign "$SIGN_ID" "$APP_BUNDLE/Contents/Helpers/codeisland-bridge" - codesign --force --options runtime --sign "$SIGN_ID" --entitlements "$ENTITLEMENTS" "$APP_BUNDLE" + codesign --force $SIGN_OPTS --sign "$SIGN_ID" "$APP_BUNDLE/Contents/Helpers/uniisland-bridge" + codesign --force $SIGN_OPTS --sign "$SIGN_ID" --entitlements "$ENTITLEMENTS" "$APP_BUNDLE" if [ "$NOTARIZE" = true ] && [[ "$SIGN_ID" == *"Developer ID"* ]]; then echo "Creating ZIP for notarization..." @@ -169,11 +188,11 @@ build_mac() { ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP_PATH" echo "Submitting for notarization..." - if xcrun notarytool submit "$ZIP_PATH" --keychain-profile "CodeIsland" --wait 2>&1 | tee /dev/stderr | grep -q "status: Accepted"; then + if xcrun notarytool submit "$ZIP_PATH" --keychain-profile "UniIsland" --wait 2>&1 | tee /dev/stderr | grep -q "status: Accepted"; then echo "Stapling notarization ticket..." xcrun stapler staple "$APP_BUNDLE" else - echo "ERROR: Notarization failed. Run 'xcrun notarytool log --keychain-profile CodeIsland' for details." + echo "ERROR: Notarization failed. Run 'xcrun notarytool log --keychain-profile UniIsland' for details." rm -f "$ZIP_PATH" exit 1 fi @@ -194,7 +213,7 @@ build_mac() { codesign --force --sign "$SIGN_ID" "$DMG_PATH" echo "Notarizing DMG..." - if xcrun notarytool submit "$DMG_PATH" --keychain-profile "CodeIsland" --wait 2>&1 | tee /dev/stderr | grep -q "status: Accepted"; then + if xcrun notarytool submit "$DMG_PATH" --keychain-profile "UniIsland" --wait 2>&1 | tee /dev/stderr | grep -q "status: Accepted"; then xcrun stapler staple "$DMG_PATH" echo "DMG ready: $DMG_PATH" else