diff --git a/.gitignore b/.gitignore index a920df9..3dce883 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ docs/superpowers/ # IDE vscode/cursor .vscode/ .cursor/ +xcuserdata/ +*.xcuserstate # Tmp files *.log @@ -25,7 +27,45 @@ android-watch/local.properties android-watch/**/build/ # Code agents config files +.adal/ +.aider-desk/ .agents/ +.augment/ +.bob/ .claude/ +.codeartsdoer/ +.codebuddy/ +.codemaker/ +.codestudio/ +.commandcode/ +.continue/ +.cortex/ +.crush/ +.devin/ +.factory/ +.forge/ +.goose/ +.hermes/ +.iflow/ +.junie/ +.kilocode/ +.kiro/ +.kode/ +.mcpjam/ +.mux/ +.neovate/ +.openhands/ +.pi/ +.pochi/ +.qoder/ +.qwen/ +.roo/ +.rovodev/ +.tabnine/ +.trae/ +.vibe/ +.windsurf/ +.zencoder/ +skills/ skills-lock.json CLAUDE.md diff --git a/Info.plist b/Info.plist index 6116510..074d2fd 100644 --- a/Info.plist +++ b/Info.plist @@ -39,5 +39,11 @@ oqLtx5s2hc8Xgsp4rEuTwnQ8UGRT4ma4tjlf+1i3YHA= SUScheduledCheckInterval 14400 + NSBonjourServices + + _codeisland._tcp + + NSLocalNetworkUsageDescription + CodeIsland advertises itself on the local network so iPhone can mirror the island state. diff --git a/README.md b/README.md index f68895a..b491ad1 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ It connects to **12 AI coding tools** via Unix socket IPC, displaying session st - **Smart suppress** — Tab-level terminal detection: only suppresses notifications when you're looking at the specific session tab, not just the terminal app - **Sound effects** — Optional 8-bit sound notifications for session events - **Auto hook install** — Automatically configures hooks for all detected CLI tools, with auto-repair and version tracking +- **iPhone & Apple Watch Buddy** — Mirror session status to Dynamic Island, Lock Screen, StandBy, and Apple Watch - **Bilingual UI** — English and Chinese, auto-detects system language - **Multi-display** — Works with external monitors, auto-detects notch displays @@ -73,6 +74,16 @@ brew install --cask codeisland > **Note:** On first launch, macOS may show a security warning. Go to **System Settings → Privacy & Security** and click **Open Anyway**. +### iPhone & Apple Watch Buddy + +Code Island Buddy is available on the App Store: + +[Download Code Island Buddy](https://apps.apple.com/us/app/code-island-buddy/id6773881129) + +The iPhone app mirrors your Mac sessions to Dynamic Island, Lock Screen, StandBy, and Apple Watch. The Mac app publishes lightweight session snapshots over your local network while the iPhone app is open, and sends compact Bluetooth summaries for background refreshes such as Live Activities and Watch updates. + +Code Island Buddy is completely free and open source. It does not require an account or an external server; the companion source code lives in this repository under `ios/CodeIslandCompanion` and `apple-companion`. + ### Build from Source Requires **macOS 14+** and **Swift 5.9+**. @@ -98,6 +109,7 @@ AI Tool (Claude/Codex/Gemini/Cursor/...) → Unix socket → /tmp/codeisland-.sock → CodeIsland app receives event → Updates UI in real time + → Optional local Buddy sync to iPhone / Apple Watch ``` 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3ac1337..ecc417f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,6 +35,7 @@ CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的 - **智能通知抑制** — 标签页级终端检测:只在你正在看该会话的标签页时抑制通知,而不是整个终端应用 - **音效提示** — 可选的 8-bit 风格音效通知 - **自动安装 Hook** — 自动为所有检测到的 CLI 工具配置 hooks,支持自动修复和版本追踪 +- **iPhone 与 Apple Watch Buddy** — 将会话状态同步到灵动岛、锁屏、StandBy 和 Apple Watch - **中英双语** — 支持中文和英文,自动跟随系统语言 - **多显示器** — 支持外接显示器,自动检测刘海屏幕 @@ -73,6 +74,16 @@ brew install --cask codeisland > **提示:** 首次启动时 macOS 可能弹出安全提示,前往 **系统设置 → 隐私与安全性** 点击 **仍要打开** 即可。 +### iPhone 与 Apple Watch Buddy + +Code Island Buddy 已在 App Store 上架: + +[下载 Code Island Buddy](https://apps.apple.com/us/app/code-island-buddy/id6773881129) + +iPhone App 可以把 Mac 上的会话状态同步到灵动岛、锁屏、StandBy 和 Apple Watch。它的工作方式很轻量:iPhone App 前台打开时,Mac 端通过本地网络发送会话快照;需要后台刷新实时活动和手表状态时,则通过蓝牙发送压缩后的状态摘要。 + +Code Island Buddy 完全免费,并且开源。它不需要账号,也不依赖外部服务器;伴随端源码就在本仓库的 `ios/CodeIslandCompanion` 和 `apple-companion` 目录中。 + ### 从源码构建 需要 **macOS 14+** 和 **Swift 5.9+**。 @@ -98,6 +109,7 @@ AI 工具 (Claude/Codex/Gemini/Cursor/...) → Unix socket → /tmp/codeisland-.sock → CodeIsland 接收事件 → 实时更新 UI + → 可选同步到 iPhone / Apple Watch Buddy ``` CodeIsland 在每个 AI 工具的配置中安装轻量级 hooks。当工具触发事件(会话开始、工具调用、权限请求等)时,hook 通过 Unix socket 发送 JSON 消息。CodeIsland 监听此 socket 并即时更新刘海面板。 diff --git a/Sources/CodeIsland/AntiGravityView.swift b/Sources/CodeIsland/AntiGravityView.swift index be98f6d..33ba01c 100644 --- a/Sources/CodeIsland/AntiGravityView.swift +++ b/Sources/CodeIsland/AntiGravityView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// AntiGravityBot — AntiGravity mascot, rainbow gradient swoosh character. /// Multicolor gradient inspired by the AntiGravity "A" logo. struct AntiGravityView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/AppDelegate.swift b/Sources/CodeIsland/AppDelegate.swift index 4e4a773..bde2652 100644 --- a/Sources/CodeIsland/AppDelegate.swift +++ b/Sources/CodeIsland/AppDelegate.swift @@ -61,6 +61,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard let appState else { return } appState.handleBuddyControlCommand(command) } + AppleCompanionPublisher.shared.attach(appState) + AppleCompanionPublisher.shared.onFocusRequest = { [weak appState] mascot in + guard let appState else { return } + ESP32FocusCoordinator.handle(mascot: mascot, appState: appState) + } + AppleCompanionPublisher.shared.onControlCommand = { [weak appState] command in + guard let appState else { return } + appState.handleBuddyControlCommand(command) + } + AppleCompanionPublisher.shared.onQuestionAnswer = { [weak appState] answer in + guard let appState else { return } + appState.answerCompanionQuestion(answer) + } let buddyEnabled = UserDefaults.standard.bool(forKey: SettingsKey.esp32BridgeEnabled) let buddySyncInterval = UserDefaults.standard.double(forKey: SettingsKey.esp32HeartbeatSeconds) let buddyBrightness = UserDefaults.standard.double(forKey: SettingsKey.buddyScreenBrightnessPercent) @@ -73,6 +86,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { brightnessPercent: buddyBrightness > 0 ? buddyBrightness : SettingsDefaults.buddyScreenBrightnessPercent, screenOrientation: buddyScreenOrientation ) + let appleCompanionEnabled = UserDefaults.standard.bool(forKey: SettingsKey.appleCompanionEnabled) + let appleCompanionHeartbeat = UserDefaults.standard.double(forKey: SettingsKey.appleCompanionHeartbeatSeconds) + AppleCompanionPublisher.shared.configure( + enabled: appleCompanionEnabled, + heartbeatSeconds: appleCompanionHeartbeat > 0 ? appleCompanionHeartbeat : SettingsDefaults.appleCompanionHeartbeatSeconds + ) // Hooks auto-recovery: periodic + app activation trigger hookRecoveryTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 6492d2f..af35579 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -625,6 +625,7 @@ final class AppState { rotatingSessionId = cachedActiveIds.first } ESP32StatePublisher.shared.notifyDirty() + AppleCompanionPublisher.shared.notifyDirty() } /// Start monitoring the CLI process for a session. @@ -875,6 +876,7 @@ final class AppState { if activeSessionCount != summary.activeSessionCount { activeSessionCount = summary.activeSessionCount } if totalSessionCount != summary.totalSessionCount { totalSessionCount = summary.totalSessionCount } ESP32StatePublisher.shared.notifyDirty() + AppleCompanionPublisher.shared.notifyDirty() } private func refreshProviderTitle(for trackedSessionId: String, providerSessionId: String? = nil) { @@ -1153,6 +1155,38 @@ final class AppState { } } + func answerCompanionQuestion(_ answer: String) { + guard !questionQueue.isEmpty else { + log.info("Ignored companion question answer because question queue is empty") + return + } + + if questionQueue[0].isFromPermission, + var askState = questionQueue[0].askUserQuestionState { + guard let index = askState.items.firstIndex(where: { askState.answers[$0.answerKey] == nil }) else { + answerQuestionMulti(askState.items.map { + (question: $0.payload.question, answer: askState.answers[$0.answerKey] ?? "") + }) + return + } + + let item = askState.items[index] + askState.answers[item.answerKey] = answer + questionQueue[0].askUserQuestionState = askState + + if askState.canConfirm { + answerQuestionMulti(askState.items.map { + (question: $0.payload.question, answer: askState.answers[$0.answerKey] ?? "") + }) + } else { + refreshDerivedState() + } + return + } + + answerQuestion(answer) + } + /// Find an existing session whose source matches and whose CLI PID equals /// the supplied ppid. Used by HookServer to merge plugin-proxied events /// (e.g. omo) into their main session when pluginSessionMode == "merge". (#123) diff --git a/Sources/CodeIsland/AppleCompanionBluetoothPeripheral.swift b/Sources/CodeIsland/AppleCompanionBluetoothPeripheral.swift new file mode 100644 index 0000000..6840bd8 --- /dev/null +++ b/Sources/CodeIsland/AppleCompanionBluetoothPeripheral.swift @@ -0,0 +1,289 @@ +import Foundation +@preconcurrency import CoreBluetooth +import os +import CodeIslandCore + +@MainActor +final class AppleCompanionBluetoothPeripheral: NSObject, ObservableObject { + static let serviceUUID = CBUUID(string: "6D951BA3-8F41-4C45-9D8A-12085E0D7A10") + static let notifyCharacteristicUUID = CBUUID(string: "25C1B67B-E903-4A0C-8A78-3EE8AB7317B7") + + private static let log = Logger(subsystem: "com.codeisland", category: "apple-companion-ble") + private static let maxChunkPayloadBytes = 120 + + @Published private(set) var poweredOn = false + @Published private(set) var advertising = false + @Published private(set) var hasSubscribers = false + @Published private(set) var lastError: String? + + private lazy var peripheralManager = CBPeripheralManager(delegate: self, queue: nil) + private var notifyCharacteristic: CBMutableCharacteristic? + private var latestChunks: [Data] = [] + private var pendingChunks: [Data] = [] + private var enabled = false + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + func configure(enabled: Bool) { + self.enabled = enabled + + guard enabled else { + peripheralManager.stopAdvertising() + peripheralManager.removeAllServices() + advertising = false + hasSubscribers = false + pendingChunks = [] + latestChunks = [] + return + } + + _ = peripheralManager + rebuildServiceIfReady() + } + + func publish(_ payload: AppleCompanionStatePayload) { + guard enabled else { return } + + do { + let summary = AppleCompanionBluetoothSummary(payload: payload) + let data = try encoder.encode(summary) + latestChunks = Self.makeChunks(sequence: payload.sequence, data: data) + lastError = nil + + if hasSubscribers { + sendLatestChunks() + } + } catch { + lastError = error.localizedDescription + Self.log.error("failed to encode BLE summary: \(error.localizedDescription)") + } + } + + private func rebuildServiceIfReady() { + guard enabled, peripheralManager.state == .poweredOn else { return } + + peripheralManager.stopAdvertising() + peripheralManager.removeAllServices() + + let characteristic = CBMutableCharacteristic( + type: Self.notifyCharacteristicUUID, + properties: [.notify], + value: nil, + permissions: [] + ) + let service = CBMutableService(type: Self.serviceUUID, primary: true) + service.characteristics = [characteristic] + notifyCharacteristic = characteristic + peripheralManager.add(service) + } + + private func startAdvertisingIfReady() { + guard enabled, poweredOn, !advertising else { return } + + peripheralManager.startAdvertising([ + CBAdvertisementDataServiceUUIDsKey: [Self.serviceUUID], + CBAdvertisementDataLocalNameKey: "CodeIsland" + ]) + advertising = true + } + + private func sendLatestChunks() { + pendingChunks = latestChunks + drainPendingChunks() + } + + private func drainPendingChunks() { + guard let notifyCharacteristic, hasSubscribers else { return } + + while !pendingChunks.isEmpty { + let chunk = pendingChunks[0] + guard peripheralManager.updateValue(chunk, for: notifyCharacteristic, onSubscribedCentrals: nil) else { + return + } + pendingChunks.removeFirst() + } + } + + private static func makeChunks(sequence: UInt64, data: Data) -> [Data] { + let chunkSize = maxChunkPayloadBytes + let total = max(1, Int(ceil(Double(data.count) / Double(chunkSize)))) + + return (0.. String { + guard text.count > limit else { return text } + return String(text.prefix(max(0, limit - 1))) + "…" + } +} + +private extension Data { + mutating func appendUInt16(_ value: UInt16) { + var bigEndian = value.bigEndian + Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) } + } + + mutating func appendUInt64(_ value: UInt64) { + var bigEndian = value.bigEndian + Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) } + } +} diff --git a/Sources/CodeIsland/AppleCompanionPublisher.swift b/Sources/CodeIsland/AppleCompanionPublisher.swift new file mode 100644 index 0000000..8c3d28a --- /dev/null +++ b/Sources/CodeIsland/AppleCompanionPublisher.swift @@ -0,0 +1,211 @@ +import Combine +import Foundation +import MultipeerConnectivity +import os +import CodeIslandCore + +@MainActor +final class AppleCompanionPublisher: NSObject, ObservableObject { + static let shared = AppleCompanionPublisher() + + private static let serviceType = "codeisland" + private static let log = Logger(subsystem: "com.codeisland", category: "apple-companion") + + @Published private(set) var enabled = false + @Published private(set) var advertising = false + @Published private(set) var connectedPeerNames: [String] = [] + @Published private(set) var lastError: String? + + var bluetoothPoweredOn: Bool { bluetooth.poweredOn } + var bluetoothAdvertising: Bool { bluetooth.advertising } + var bluetoothSubscribed: Bool { bluetooth.hasSubscribers } + + var onControlCommand: ((BuddyControlCommand) -> Void)? + var onFocusRequest: ((MascotID) -> Void)? + var onQuestionAnswer: ((String) -> Void)? + + private weak var appState: AppState? + private let peerID: MCPeerID + private lazy var session = MCSession(peer: peerID, securityIdentity: nil, encryptionPreference: .required) + private lazy var advertiser = MCNearbyServiceAdvertiser( + peer: peerID, + discoveryInfo: ["protocol": "1"], + serviceType: Self.serviceType + ) + private var heartbeatTimer: Timer? + private var sequence: UInt64 = 0 + private let bluetooth = AppleCompanionBluetoothPeripheral() + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + private override init() { + let hostName = Host.current().localizedName ?? "Mac" + let displayName = "CodeIsland \(hostName)" + self.peerID = MCPeerID(displayName: String(displayName.prefix(63))) + super.init() + self.session.delegate = self + self.advertiser.delegate = self + } + + func attach(_ appState: AppState) { + self.appState = appState + } + + func configure(enabled: Bool, heartbeatSeconds: Double) { + self.enabled = enabled + heartbeatTimer?.invalidate() + heartbeatTimer = nil + + guard enabled else { + advertiser.stopAdvertisingPeer() + bluetooth.configure(enabled: false) + advertising = false + connectedPeerNames = [] + session.disconnect() + return + } + + lastError = nil + advertiser.startAdvertisingPeer() + bluetooth.configure(enabled: true) + advertising = true + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: max(1.0, heartbeatSeconds), repeats: true) { [weak self] _ in + Task { @MainActor in + self?.flush(reason: "heartbeat") + } + } + flush(reason: "enabled") + } + + func notifyDirty() { + flush(reason: "change") + } + + func reconnect() { + guard enabled else { return } + advertiser.stopAdvertisingPeer() + session.disconnect() + connectedPeerNames = [] + advertiser.startAdvertisingPeer() + advertising = true + bluetooth.configure(enabled: true) + flush(reason: "reconnect") + } + + private func flush(reason: String) { + guard enabled, let appState else { return } + sequence &+= 1 + let payload = appState.appleCompanionStatePayload(sequence: sequence) + + bluetooth.publish(payload) + + guard !session.connectedPeers.isEmpty else { return } + do { + let data = try encoder.encode(payload) + try session.send(data, toPeers: session.connectedPeers, with: .reliable) + Self.log.debug("push(\(reason)): seq=\(payload.sequence) source=\(payload.source) status=\(payload.status.rawValue) peers=\(self.session.connectedPeers.count)") + } catch { + lastError = error.localizedDescription + Self.log.error("push failed: \(error.localizedDescription)") + } + } + + private func handleCommand(_ command: AppleCompanionCommandPayload) { + switch command.type { + case .requestCurrentState: + flush(reason: "requested") + case .approveCurrentPermission: + onControlCommand?(.approveCurrentPermission) + case .denyCurrentPermission: + onControlCommand?(.denyCurrentPermission) + case .skipCurrentQuestion: + onControlCommand?(.skipCurrentQuestion) + case .answerQuestion: + if let answer = command.answer?.trimmingCharacters(in: .whitespacesAndNewlines), + !answer.isEmpty { + onQuestionAnswer?(answer) + } + case .focus: + onFocusRequest?(MascotID(sourceName: command.source) ?? .claude) + } + } + + private func refreshConnectedPeers() { + connectedPeerNames = session.connectedPeers.map(\.displayName).sorted() + } +} + +extension AppleCompanionPublisher: MCNearbyServiceAdvertiserDelegate { + nonisolated func advertiser( + _ advertiser: MCNearbyServiceAdvertiser, + didReceiveInvitationFromPeer peerID: MCPeerID, + withContext context: Data?, + invitationHandler: @escaping (Bool, MCSession?) -> Void + ) { + Task { @MainActor in + guard self.enabled else { + invitationHandler(false, nil) + return + } + Self.log.info("accepted invitation from \(peerID.displayName)") + invitationHandler(true, self.session) + } + } + + nonisolated func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error) { + Task { @MainActor in + self.advertising = false + self.lastError = error.localizedDescription + Self.log.error("advertising failed: \(error.localizedDescription)") + } + } +} + +extension AppleCompanionPublisher: MCSessionDelegate { + nonisolated func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + Task { @MainActor in + self.refreshConnectedPeers() + if state == .connected { + self.flush(reason: "peer-connected") + } + } + } + + nonisolated func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + Task { @MainActor in + do { + let command = try self.decoder.decode(AppleCompanionCommandPayload.self, from: data) + self.handleCommand(command) + } catch { + self.lastError = "Ignored command from \(peerID.displayName): \(error.localizedDescription)" + } + } + } + + nonisolated func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {} + + nonisolated func session( + _ session: MCSession, + didStartReceivingResourceWithName resourceName: String, + fromPeer peerID: MCPeerID, + with progress: Progress + ) {} + + nonisolated func session( + _ session: MCSession, + didFinishReceivingResourceWithName resourceName: String, + fromPeer peerID: MCPeerID, + at localURL: URL?, + withError error: Error? + ) {} +} diff --git a/Sources/CodeIsland/BuddyView.swift b/Sources/CodeIsland/BuddyView.swift index 7c7ff79..40d8f4b 100644 --- a/Sources/CodeIsland/BuddyView.swift +++ b/Sources/CodeIsland/BuddyView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// Buddy — CodeBuddy mascot, pixel-art cat astronaut. /// Purple #6C4DFF body with cyan-green #32E6B9 accents. Tencent Cloud style. struct BuddyView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/ClineView.swift b/Sources/CodeIsland/ClineView.swift index 50123fc..864651f 100644 --- a/Sources/CodeIsland/ClineView.swift +++ b/Sources/CodeIsland/ClineView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// ClineBot — Cline (saoudrizwan.claude-dev) VSCode extension mascot. /// A compact green robot with a wrench, echoing Cline's tool-use identity. struct ClineView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed @@ -145,7 +144,9 @@ struct ClineView: View { let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) let baseOp = 0.7 - ci * 0.1 let opacity = phase < 0.8 ? baseOp : (1.0 - phase) * 3.5 * baseOp - let xOff = size * CGFloat(0.15 + ci * 0.08 + sin(phase * .pi * 2) * 0.03) + let wave = sin(phase * Double.pi * 2.0) * 0.03 + let xOffsetRatio = 0.15 + ci * 0.08 + wave + let xOff = size * CGFloat(xOffsetRatio) let yOff = -size * CGFloat(0.15 + phase * 0.38) Text("z") .font(.system(size: fontSize, weight: .black, design: .monospaced)) diff --git a/Sources/CodeIsland/CopilotView.swift b/Sources/CodeIsland/CopilotView.swift index 9925114..b654634 100644 --- a/Sources/CodeIsland/CopilotView.swift +++ b/Sources/CodeIsland/CopilotView.swift @@ -1,11 +1,10 @@ import SwiftUI -import CodeIslandCore /// CopilotBot — GitHub Copilot CLI mascot, adapted from copilot-avatar.svg. /// Two hollow ear loops (╭─╮╭─╮) on top, rose-framed face with gold dot eyes, /// and a pink mouth bar — a cute, minimal character. struct CopilotView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed @@ -150,7 +149,9 @@ struct CopilotView: View { let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) let baseOpacity = 0.7 - ci * 0.1 let opacity = phase < 0.8 ? baseOpacity : (1.0 - phase) * 3.5 * baseOpacity - let xOff = size * CGFloat(0.08 + ci * 0.06 + sin(phase * .pi * 2) * 0.03) + let wave = sin(phase * Double.pi * 2.0) * 0.03 + let xOffsetRatio = 0.08 + ci * 0.06 + wave + let xOff = size * CGFloat(xOffsetRatio) let yOff = -size * CGFloat(0.15 + phase * 0.38) Text("z") .font(.system(size: fontSize, weight: .black, design: .monospaced)) diff --git a/Sources/CodeIsland/CursorView.swift b/Sources/CodeIsland/CursorView.swift index 91a3e23..6249b56 100644 --- a/Sources/CodeIsland/CursorView.swift +++ b/Sources/CodeIsland/CursorView.swift @@ -1,11 +1,10 @@ import SwiftUI -import CodeIslandCore /// 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. /// Warm dark #14120B body, light face #EDECEC highlight. struct CursorView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/DexView.swift b/Sources/CodeIsland/DexView.swift index 68d28d7..01c1a15 100644 --- a/Sources/CodeIsland/DexView.swift +++ b/Sources/CodeIsland/DexView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// Dex — Codex mascot, pixel-art cloud with terminal prompt face. /// Inspired by Codex's cloud icon with `>_` symbol. OpenAI black & white style. struct DexView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed @@ -156,7 +155,9 @@ struct DexView: View { let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) let baseOpacity = 0.7 - ci * 0.1 let opacity = phase < 0.8 ? baseOpacity : (1.0 - phase) * 3.5 * baseOpacity - let xOff = size * CGFloat(0.08 + ci * 0.06 + sin(phase * .pi * 2) * 0.03) + let wave = sin(phase * Double.pi * 2.0) * 0.03 + let xOffsetRatio = 0.08 + ci * 0.06 + wave + let xOff = size * CGFloat(xOffsetRatio) let yOff = -size * CGFloat(0.15 + phase * 0.38) Text("z") .font(.system(size: fontSize, weight: .black, design: .monospaced)) diff --git a/Sources/CodeIsland/DroidView.swift b/Sources/CodeIsland/DroidView.swift index aaaedce..605975b 100644 --- a/Sources/CodeIsland/DroidView.swift +++ b/Sources/CodeIsland/DroidView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// DroidBot — Factory/Droid mascot, pixel-art industrial robot. /// Rust orange #D56A26 on warm brown-black #161413. Mechanical/factory aesthetic. struct DroidView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/ESP32StatePublisher.swift b/Sources/CodeIsland/ESP32StatePublisher.swift index 21d916f..b5e8338 100644 --- a/Sources/CodeIsland/ESP32StatePublisher.swift +++ b/Sources/CodeIsland/ESP32StatePublisher.swift @@ -339,6 +339,130 @@ extension AppState { } } + func appleCompanionStatePayload(sequence: UInt64, session: SessionSnapshot? = nil) -> AppleCompanionStatePayload { + let displaySession = session ?? esp32DisplaySession() + let context = esp32DisplayContext(session: displaySession) + let displaySessionId = rotatingSessionId ?? activeSessionId ?? sessions.keys.sorted().first + let sessionId = pendingPermission?.event.sessionId + ?? pendingQuestion?.event.sessionId + ?? displaySessionId + let pendingAction: AppleCompanionPendingAction? + switch context.status { + case .waitingApproval: + pendingAction = .approval + case .waitingQuestion: + pendingAction = .question + default: + pendingAction = nil + } + let messages = context.messages.suffix(3).compactMap { message -> AppleCompanionMessagePreview? in + let text = Self.appleCompanionPreviewText(message.text) + guard !text.isEmpty else { return nil } + return AppleCompanionMessagePreview( + role: message.isUser ? .user : .assistant, + text: text + ) + } + let questionPayload = appleCompanionQuestionPayload() + return AppleCompanionStatePayload( + sequence: sequence, + sessionId: sessionId, + source: context.source, + status: AppleCompanionStatus(context.status), + toolName: context.tool, + workspaceName: context.workspace, + messages: messages, + pendingAction: pendingAction, + question: questionPayload, + sessions: appleCompanionSessionPreviews(primarySessionId: sessionId) + ) + } + + private func appleCompanionSessionPreviews(primarySessionId: String?) -> [AppleCompanionSessionPreview] { + let sorted = sessions.sorted { lhs, rhs in + let leftPrimary = lhs.key == primarySessionId + let rightPrimary = rhs.key == primarySessionId + if leftPrimary != rightPrimary { return leftPrimary } + + let leftPriority = appleCompanionSessionPriority(lhs.value.status) + let rightPriority = appleCompanionSessionPriority(rhs.value.status) + if leftPriority != rightPriority { return leftPriority > rightPriority } + + return lhs.value.lastActivity > rhs.value.lastActivity + } + + return sorted.prefix(5).map { sessionId, session in + AppleCompanionSessionPreview( + sessionId: sessionId, + source: session.source, + status: AppleCompanionStatus(session.status), + toolName: session.status == .idle ? nil : session.currentTool, + workspaceName: session.projectDisplayName, + message: appleCompanionSessionMessage(sessionId: sessionId, session: session), + updatedAt: session.lastActivity + ) + } + } + + private func appleCompanionSessionMessage(sessionId: String, session: SessionSnapshot) -> String? { + if let pending = questionQueue.first(where: { ($0.event.sessionId ?? "default") == sessionId }) { + return Self.appleCompanionPreviewText(pending.question.question) + } + if let pending = permissionQueue.first(where: { ($0.event.sessionId ?? "default") == sessionId }) { + return Self.appleCompanionPreviewText(pending.event.toolDescription ?? session.toolDescription) + } + if let text = session.recentMessages.last?.text { + return Self.appleCompanionPreviewText(text) + } + return Self.appleCompanionPreviewText(session.lastAssistantMessage ?? session.lastUserPrompt) + } + + private func appleCompanionSessionPriority(_ status: AgentStatus) -> Int { + switch status { + case .waitingApproval: return 5 + case .waitingQuestion: return 4 + case .running: return 3 + case .processing: return 2 + case .idle: return 0 + } + } + + private func appleCompanionQuestionPayload() -> AppleCompanionQuestionPayload? { + guard let pending = pendingQuestion else { return nil } + + if let askState = pending.askUserQuestionState, !askState.items.isEmpty { + let index = askState.items.firstIndex { askState.answers[$0.answerKey] == nil } ?? 0 + let item = askState.items[index] + return AppleCompanionQuestionPayload( + header: item.payload.header, + question: item.payload.question, + options: item.payload.options ?? [], + descriptions: item.payload.descriptions ?? [], + index: index + 1, + total: askState.items.count, + allowsMultipleSelection: item.multiSelect + ) + } + + return AppleCompanionQuestionPayload( + header: pending.question.header, + question: pending.question.question, + options: pending.question.options ?? [], + descriptions: pending.question.descriptions ?? [], + index: 1, + total: 1, + allowsMultipleSelection: false + ) + } + + private static func appleCompanionPreviewText(_ text: String?) -> String { + guard let text else { return "" } + let collapsed = text + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard collapsed.count > 240 else { return collapsed } + return String(collapsed.prefix(237)) + "..." + } func esp32MessagePreviewSegments(text: String?) -> [String] { guard let text else { return [] } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodeIsland/GeminiView.swift b/Sources/CodeIsland/GeminiView.swift index d173851..cbcb569 100644 --- a/Sources/CodeIsland/GeminiView.swift +++ b/Sources/CodeIsland/GeminiView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// Gemini — Google Gemini CLI mascot, four-pointed sparkle star. /// Blue→Purple→Rose gradient (#4796E4 → #847ACE → #C3677F). struct GeminiView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed @@ -133,7 +132,9 @@ struct GeminiView: View { let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) let baseOp = 0.7 - ci * 0.1 let opacity = phase < 0.8 ? baseOp : (1.0 - phase) * 3.5 * baseOp - let xOff = size * CGFloat(0.15 + ci * 0.08 + sin(phase * .pi * 2) * 0.03) + let wave = sin(phase * Double.pi * 2.0) * 0.03 + let xOffsetRatio = 0.15 + ci * 0.08 + wave + let xOff = size * CGFloat(xOffsetRatio) let yOff = -size * CGFloat(0.15 + phase * 0.38) Text("z") .font(.system(size: fontSize, weight: .black, design: .monospaced)) diff --git a/Sources/CodeIsland/HermesView.swift b/Sources/CodeIsland/HermesView.swift index 3884af1..a7e259b 100644 --- a/Sources/CodeIsland/HermesView.swift +++ b/Sources/CodeIsland/HermesView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// HermesBot — Hermes mascot, dark hooded figure with glowing eyes. /// Noir purple #2D1B4E with white highlights, mysterious aesthetic. struct HermesView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/KimiView.swift b/Sources/CodeIsland/KimiView.swift index 0d30838..8a63524 100644 --- a/Sources/CodeIsland/KimiView.swift +++ b/Sources/CodeIsland/KimiView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// KimiBot — Kimi Code CLI mascot. /// Soft rounded cube with a small antenna, in Kimi blue. struct KimiView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed @@ -126,7 +125,9 @@ struct KimiView: View { let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) let baseOp = 0.7 - ci * 0.1 let opacity = phase < 0.8 ? baseOp : (1.0 - phase) * 3.5 * baseOp - let xOff = size * CGFloat(0.15 + ci * 0.08 + sin(phase * .pi * 2) * 0.03) + let wave = sin(phase * Double.pi * 2.0) * 0.03 + let xOffsetRatio = 0.15 + ci * 0.08 + wave + let xOff = size * CGFloat(xOffsetRatio) let yOff = -size * CGFloat(0.15 + phase * 0.38) Text("z") .font(.system(size: fontSize, weight: .black, design: .monospaced)) diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 8ea6fa8..c781141 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -280,6 +280,16 @@ final class L10n: ObservableObject { "buddy_screen_orientation_down": "Down", "buddy_screen_orientation_desc": "Choose Buddy's physical direction. Down flips the screen 180°.", "buddy_desc": "Buddy mirrors the current island mascot and status over Bluetooth, and its button can focus the matching agent terminal.", + "apple_companion": "iPhone Buddy", + "apple_companion_enable": "Allow iPhone Buddy to discover this Mac", + "apple_companion_status": "Connection", + "apple_companion_status_off": "Off", + "apple_companion_status_waiting": "Waiting for iPhone", + "apple_companion_status_connected": "Connected", + "apple_companion_no_devices": "No iPhone Buddy connected", + "apple_companion_sync_interval": "Sync interval", + "apple_companion_restart": "Restart iPhone Buddy", + "apple_companion_desc": "Mirrors CodeIsland status to the iPhone app over the local network while foregrounded, and sends lightweight Bluetooth summaries for Live Activity and Apple Watch refreshes in the background.", // About "about_desc1": "Real-time AI coding agent status panel for macOS", @@ -590,6 +600,16 @@ final class L10n: ObservableObject { "buddy_screen_orientation_down": "下", "buddy_screen_orientation_desc": "选择 Buddy 的物理朝向。选择“下”会将屏幕旋转 180°。", "buddy_desc": "Buddy 会通过蓝牙同步当前灵动岛的角色和状态,也可以用机身按钮切换到对应 Agent 的终端。", + "apple_companion": "iPhone Buddy", + "apple_companion_enable": "允许 iPhone Buddy 发现这台 Mac", + "apple_companion_status": "连接状态", + "apple_companion_status_off": "已关闭", + "apple_companion_status_waiting": "等待 iPhone 连接", + "apple_companion_status_connected": "已连接", + "apple_companion_no_devices": "暂无 iPhone Buddy 连接", + "apple_companion_sync_interval": "同步间隔", + "apple_companion_restart": "重启 iPhone Buddy 广播", + "apple_companion_desc": "前台通过本地网络把 CodeIsland 状态同步到 iPhone App;后台会通过蓝牙发送轻量状态摘要,用于刷新实时活动和 Apple Watch。", // About "about_desc1": "macOS 实时 AI 编码 Agent 状态面板", @@ -900,6 +920,16 @@ final class L10n: ObservableObject { "buddy_screen_orientation_down": "下", "buddy_screen_orientation_desc": "Buddy の物理的な向きを選びます。下を選ぶと画面が 180° 回転します。", "buddy_desc": "Buddy は現在のアイランドのマスコットと状態を Bluetooth で同期し、本体ボタンで対応するエージェントのターミナルにフォーカスできます。", + "apple_companion": "iPhone Buddy", + "apple_companion_enable": "iPhone がこの Mac を検出できるようにする", + "apple_companion_status": "接続状態", + "apple_companion_status_off": "オフ", + "apple_companion_status_waiting": "iPhone の接続待ち", + "apple_companion_status_connected": "接続済み", + "apple_companion_no_devices": "接続中の iPhone はありません", + "apple_companion_sync_interval": "同期間隔", + "apple_companion_restart": "広告を再開", + "apple_companion_desc": "フォアグラウンドではローカルネットワークで CodeIsland の状態を iPhone App に同期し、バックグラウンドでは Live Activity と Apple Watch の更新用に軽量な Bluetooth サマリーを送信します。", // About "about_desc1": "macOS 向けリアルタイム AI コーディングエージェント状態パネル", @@ -1210,6 +1240,16 @@ final class L10n: ObservableObject { "buddy_screen_orientation_down": "아래", "buddy_screen_orientation_desc": "Buddy의 물리적 방향을 선택합니다. 아래를 선택하면 화면이 180° 회전합니다.", "buddy_desc": "Buddy는 현재 아일랜드의 마스코트와 상태를 Bluetooth로 동기화하며, 기기 버튼으로 해당 에이전트 터미널에 포커스할 수 있습니다.", + "apple_companion": "iPhone Buddy", + "apple_companion_enable": "iPhone이 이 Mac을 발견하도록 허용", + "apple_companion_status": "연결 상태", + "apple_companion_status_off": "꺼짐", + "apple_companion_status_waiting": "iPhone 연결 대기 중", + "apple_companion_status_connected": "연결됨", + "apple_companion_no_devices": "연결된 iPhone 없음", + "apple_companion_sync_interval": "동기화 간격", + "apple_companion_restart": "광고 다시 시작", + "apple_companion_desc": "포그라운드에서는 로컬 네트워크로 CodeIsland 상태를 iPhone 앱에 동기화하고, 백그라운드에서는 Live Activity와 Apple Watch 갱신을 위해 가벼운 Bluetooth 요약을 보냅니다.", // About "about_desc1": "macOS용 실시간 AI 코딩 에이전트 상태 패널", @@ -1520,6 +1560,16 @@ final class L10n: ObservableObject { "buddy_screen_orientation_down": "Aşağı", "buddy_screen_orientation_desc": "Buddy'nin fiziksel yönünü seçin. Aşağı seçeneği ekranı 180° döndürür.", "buddy_desc": "Buddy, geçerli ada maskotunu ve durumunu Bluetooth ile eşler; üzerindeki düğme de ilgili ajan terminaline odaklanabilir.", + "apple_companion": "iPhone Buddy", + "apple_companion_enable": "iPhone'un bu Mac'i bulmasına izin ver", + "apple_companion_status": "Bağlantı durumu", + "apple_companion_status_off": "Kapalı", + "apple_companion_status_waiting": "iPhone bekleniyor", + "apple_companion_status_connected": "Bağlandı", + "apple_companion_no_devices": "Bağlı iPhone yok", + "apple_companion_sync_interval": "Senkronizasyon aralığı", + "apple_companion_restart": "Yayını yeniden başlat", + "apple_companion_desc": "Öndeyken CodeIsland durumunu yerel ağ üzerinden iPhone uygulamasına eşler; arka planda Live Activity ve Apple Watch yenilemeleri için hafif Bluetooth özetleri gönderir.", // About "about_desc1": "macOS için gerçek zamanlı AI kodlama ajanı durum paneli", diff --git a/Sources/CodeIsland/MascotAgentStatus.swift b/Sources/CodeIsland/MascotAgentStatus.swift new file mode 100644 index 0000000..30abd68 --- /dev/null +++ b/Sources/CodeIsland/MascotAgentStatus.swift @@ -0,0 +1,3 @@ +import CodeIslandCore + +typealias MascotAgentStatus = AgentStatus diff --git a/Sources/CodeIsland/MascotView.swift b/Sources/CodeIsland/MascotView.swift index 1d3a7ca..3d1d4b5 100644 --- a/Sources/CodeIsland/MascotView.swift +++ b/Sources/CodeIsland/MascotView.swift @@ -1,5 +1,4 @@ import SwiftUI -import CodeIslandCore // MARK: - Mascot Animation Speed Environment @@ -17,7 +16,7 @@ extension EnvironmentValues { /// Routes a CLI source identifier to the correct pixel mascot view. struct MascotView: View { let source: String - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @AppStorage(SettingsKey.mascotSpeed) private var speedPct = SettingsDefaults.mascotSpeed diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 19a4fa0..4af5d43 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -640,12 +640,13 @@ private struct IdleIndicatorBar: View { let hovered: Bool @ObservedObject private var l10n = L10n.shared @AppStorage(SettingsKey.soundEnabled) private var soundEnabled = SettingsDefaults.soundEnabled + @AppStorage(SettingsKey.defaultSource) private var defaultSource = SettingsDefaults.defaultSource var body: some View { HStack(spacing: 0) { // Left: mascot HStack(spacing: 6) { - MascotView(source: "claude", status: .idle, size: mascotSize) + MascotView(source: defaultSource, status: .idle, size: mascotSize) .opacity(hovered ? 0.9 : 0.5) } .padding(.leading, 6) diff --git a/Sources/CodeIsland/OpenCodeView.swift b/Sources/CodeIsland/OpenCodeView.swift index eb17859..99e2981 100644 --- a/Sources/CodeIsland/OpenCodeView.swift +++ b/Sources/CodeIsland/OpenCodeView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// OpBot — OpenCode mascot, pixel-art dark terminal block with `{ }` face. /// Minimalist geometric style matching OpenCode's monochrome branding. struct OpenCodeView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/PixelCharacterView.swift b/Sources/CodeIsland/PixelCharacterView.swift index adcc4dc..c2377d8 100644 --- a/Sources/CodeIsland/PixelCharacterView.swift +++ b/Sources/CodeIsland/PixelCharacterView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// Clawd — Claude mascot, adapted from clawd-on-desk SVG pixel art. /// Renders SVG rects proportionally via Canvas + TimelineView animations. struct ClawdView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/QoderView.swift b/Sources/CodeIsland/QoderView.swift index 953c5f4..309b482 100644 --- a/Sources/CodeIsland/QoderView.swift +++ b/Sources/CodeIsland/QoderView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// QoderBot — Qoder mascot, pixel-art chat bubble with "Q" face. /// Brand lime green #2ADB5C on dark background. struct QoderView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/QwenView.swift b/Sources/CodeIsland/QwenView.swift index 5b34cac..a2b1a07 100644 --- a/Sources/CodeIsland/QwenView.swift +++ b/Sources/CodeIsland/QwenView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// QwenBot — Qwen Code mascot, interlocking geometric star. /// Purple-violet gradient (#7C3AED → #6D28D9 → #8B5CF6) from Qwen brand. struct QwenView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed @@ -144,7 +143,9 @@ struct QwenView: View { let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) let baseOp = 0.7 - ci * 0.1 let opacity = phase < 0.8 ? baseOp : (1.0 - phase) * 3.5 * baseOp - let xOff = size * CGFloat(0.15 + ci * 0.08 + sin(phase * .pi * 2) * 0.03) + let wave = sin(phase * Double.pi * 2.0) * 0.03 + let xOffsetRatio = 0.15 + ci * 0.08 + wave + let xOff = size * CGFloat(xOffsetRatio) let yOff = -size * CGFloat(0.15 + phase * 0.38) Text("z") .font(.system(size: fontSize, weight: .black, design: .monospaced)) diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 301aa56..30f2aa6 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -94,6 +94,10 @@ enum SettingsKey { static let selectedBuddyIdentifier = "selectedBuddyIdentifier" static let selectedBuddyName = "selectedBuddyName" + // Apple companion (iPhone / StandBy / Apple Watch prototype) + static let appleCompanionEnabled = "appleCompanionEnabled" + static let appleCompanionHeartbeatSeconds = "appleCompanionHeartbeatSeconds" + // Auto-approve tools (comma-separated tool names) static let autoApproveTools = "autoApproveTools" @@ -159,6 +163,9 @@ struct SettingsDefaults { static let selectedBuddyIdentifier = "" static let selectedBuddyName = "" + static let appleCompanionEnabled = false + static let appleCompanionHeartbeatSeconds = 5.0 + // Default to no auto-approval — every tool call goes through the // approval flow and the user opts in per tool. The previous default // silently approved 9 internal agent tools (TaskCreate, TodoWrite, @@ -220,6 +227,8 @@ class SettingsManager { SettingsKey.buddyScreenOrientation: SettingsDefaults.buddyScreenOrientation, SettingsKey.selectedBuddyIdentifier: SettingsDefaults.selectedBuddyIdentifier, SettingsKey.selectedBuddyName: SettingsDefaults.selectedBuddyName, + SettingsKey.appleCompanionEnabled: SettingsDefaults.appleCompanionEnabled, + SettingsKey.appleCompanionHeartbeatSeconds: SettingsDefaults.appleCompanionHeartbeatSeconds, SettingsKey.defaultSource: SettingsDefaults.defaultSource, SettingsKey.autoApproveTools: SettingsDefaults.autoApproveTools, SettingsKey.excludedHookCwdSubstrings: SettingsDefaults.excludedHookCwdSubstrings, diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 4217528..fbf36c7 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -1242,6 +1242,9 @@ private struct BuddyPage: View { @AppStorage(SettingsKey.esp32HeartbeatSeconds) private var heartbeat: Double = SettingsDefaults.esp32HeartbeatSeconds @AppStorage(SettingsKey.buddyScreenBrightnessPercent) private var brightness: Double = SettingsDefaults.buddyScreenBrightnessPercent @AppStorage(SettingsKey.buddyScreenOrientation) private var screenOrientation: String = SettingsDefaults.buddyScreenOrientation + @AppStorage(SettingsKey.appleCompanionEnabled) private var appleCompanionEnabled: Bool = SettingsDefaults.appleCompanionEnabled + @AppStorage(SettingsKey.appleCompanionHeartbeatSeconds) private var appleCompanionHeartbeat: Double = SettingsDefaults.appleCompanionHeartbeatSeconds + @ObservedObject private var appleCompanion = AppleCompanionPublisher.shared @State private var refreshTick = 0 private var bridge: ESP32BridgeManager { ESP32BridgeManager.shared } @@ -1297,6 +1300,13 @@ private struct BuddyPage: View { ) } + private func configureAppleCompanion() { + AppleCompanionPublisher.shared.configure( + enabled: appleCompanionEnabled, + heartbeatSeconds: appleCompanionHeartbeat + ) + } + var body: some View { Form { Section(l10n["buddy"]) { @@ -1480,6 +1490,67 @@ private struct BuddyPage: View { .foregroundStyle(.secondary) } + Section(l10n["apple_companion"]) { + Toggle(l10n["apple_companion_enable"], isOn: $appleCompanionEnabled) + .onChange(of: appleCompanionEnabled) { _, _ in + configureAppleCompanion() + } + + HStack { + Text(l10n["apple_companion_status"]) + Spacer() + Circle() + .fill(appleCompanionStatusColor) + .frame(width: 8, height: 8) + Text(appleCompanionStatusText) + .foregroundStyle(.secondary) + .font(.caption) + .lineLimit(1) + } + + if appleCompanion.connectedPeerNames.isEmpty { + Label(l10n["apple_companion_no_devices"], systemImage: "iphone") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(appleCompanion.connectedPeerNames, id: \.self) { name in + Label(name, systemImage: "iphone") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + HStack { + Text(l10n["apple_companion_sync_interval"]) + Spacer() + Text(String(format: l10n["buddy_seconds_format"], appleCompanionHeartbeat)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $appleCompanionHeartbeat, in: 1...30, step: 1) + .onChange(of: appleCompanionHeartbeat) { _, _ in + configureAppleCompanion() + } + .disabled(!appleCompanionEnabled) + + Button { + appleCompanion.reconnect() + } label: { + Label(l10n["apple_companion_restart"], systemImage: "arrow.triangle.2.circlepath") + } + .disabled(!appleCompanionEnabled) + + if let error = appleCompanion.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Text(l10n["apple_companion_desc"]) + .font(.caption) + .foregroundStyle(.secondary) + } + Section { Text(l10n["buddy_desc"]) .font(.caption) @@ -1515,6 +1586,20 @@ private struct BuddyPage: View { default: return "wifi" } } + + private var appleCompanionStatusText: String { + guard appleCompanion.enabled else { + return l10n["apple_companion_status_off"] + } + return appleCompanion.connectedPeerNames.isEmpty + ? l10n["apple_companion_status_waiting"] + : l10n["apple_companion_status_connected"] + } + + private var appleCompanionStatusColor: Color { + guard appleCompanion.enabled else { return .secondary } + return appleCompanion.connectedPeerNames.isEmpty ? .orange : .green + } } // MARK: - About Page diff --git a/Sources/CodeIsland/StepFunView.swift b/Sources/CodeIsland/StepFunView.swift index 5cc116f..9cdf2ea 100644 --- a/Sources/CodeIsland/StepFunView.swift +++ b/Sources/CodeIsland/StepFunView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// StepFunBot — StepFun mascot, pixel-block staircase character. /// Dark teal #0D9488 with blocky pixel aesthetic matching the step-pattern logo. struct StepFunView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIsland/TraeView.swift b/Sources/CodeIsland/TraeView.swift index 79cbdbf..81bb9fc 100644 --- a/Sources/CodeIsland/TraeView.swift +++ b/Sources/CodeIsland/TraeView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// TraeBot — Trae mascot, rounded terminal-screen character. /// Bright green (#22C55E) on dark, resembling a glowing terminal. struct TraeView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed @@ -136,7 +135,9 @@ struct TraeView: View { let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) let baseOp = 0.7 - ci * 0.1 let opacity = phase < 0.8 ? baseOp : (1.0 - phase) * 3.5 * baseOp - let xOff = size * CGFloat(0.15 + ci * 0.08 + sin(phase * .pi * 2) * 0.03) + let wave = sin(phase * Double.pi * 2.0) * 0.03 + let xOffsetRatio = 0.15 + ci * 0.08 + wave + let xOff = size * CGFloat(xOffsetRatio) let yOff = -size * CGFloat(0.15 + phase * 0.38) Text("z") .font(.system(size: fontSize, weight: .black, design: .monospaced)) diff --git a/Sources/CodeIsland/WorkBuddyView.swift b/Sources/CodeIsland/WorkBuddyView.swift index 5d45ce6..62da824 100644 --- a/Sources/CodeIsland/WorkBuddyView.swift +++ b/Sources/CodeIsland/WorkBuddyView.swift @@ -1,10 +1,9 @@ import SwiftUI -import CodeIslandCore /// WorkBuddyBot — WorkBuddy mascot, teal rounded robot character. /// Teal-cyan #32E6B9 with rounded friendly aesthetic. struct WorkBuddyView: View { - let status: AgentStatus + let status: MascotAgentStatus var size: CGFloat = 27 @State private var alive = false @Environment(\.mascotSpeed) private var speed diff --git a/Sources/CodeIslandCore/AppleCompanionPayload.swift b/Sources/CodeIslandCore/AppleCompanionPayload.swift new file mode 100644 index 0000000..b15877a --- /dev/null +++ b/Sources/CodeIslandCore/AppleCompanionPayload.swift @@ -0,0 +1,194 @@ +import Foundation + +public enum AppleCompanionStatus: String, Codable, Equatable, Sendable { + case idle + case processing + case running + case waitingApproval + case waitingQuestion + + public init(_ status: AgentStatus) { + switch status { + case .idle: self = .idle + case .processing: self = .processing + case .running: self = .running + case .waitingApproval: self = .waitingApproval + case .waitingQuestion: self = .waitingQuestion + } + } +} + +public enum AppleCompanionPendingAction: String, Codable, Equatable, Sendable { + case approval + case question +} + +public enum AppleCompanionMessageRole: String, Codable, Equatable, Sendable { + case user + case assistant +} + +public struct AppleCompanionMessagePreview: Codable, Equatable, Sendable { + public let role: AppleCompanionMessageRole + public let text: String + + public init(role: AppleCompanionMessageRole, text: String) { + self.role = role + self.text = text + } +} + +public struct AppleCompanionQuestionPayload: Codable, Equatable, Sendable { + public let header: String? + public let question: String + public let options: [String] + public let descriptions: [String] + public let index: Int + public let total: Int + public let allowsMultipleSelection: Bool + + public init( + header: String?, + question: String, + options: [String], + descriptions: [String], + index: Int, + total: Int, + allowsMultipleSelection: Bool + ) { + self.header = header + self.question = question + self.options = options + self.descriptions = descriptions + self.index = index + self.total = total + self.allowsMultipleSelection = allowsMultipleSelection + } +} + +public struct AppleCompanionSessionPreview: Codable, Equatable, Sendable { + public let sessionId: String? + public let source: String + public let status: AppleCompanionStatus + public let toolName: String? + public let workspaceName: String? + public let message: String? + public let updatedAt: Date + + public init( + sessionId: String?, + source: String, + status: AppleCompanionStatus, + toolName: String?, + workspaceName: String?, + message: String?, + updatedAt: Date = Date() + ) { + self.sessionId = sessionId + self.source = source + self.status = status + self.toolName = toolName + self.workspaceName = workspaceName + self.message = message + self.updatedAt = updatedAt + } +} + +public struct AppleCompanionStatePayload: Codable, Equatable, Sendable { + public let version: Int + public let sequence: UInt64 + public let sessionId: String? + public let source: String + public let status: AppleCompanionStatus + public let toolName: String? + public let workspaceName: String? + public let messages: [AppleCompanionMessagePreview] + public let pendingAction: AppleCompanionPendingAction? + public let question: AppleCompanionQuestionPayload? + public let sessions: [AppleCompanionSessionPreview] + public let updatedAt: Date + + public init( + version: Int = 1, + sequence: UInt64, + sessionId: String?, + source: String, + status: AppleCompanionStatus, + toolName: String?, + workspaceName: String?, + messages: [AppleCompanionMessagePreview], + pendingAction: AppleCompanionPendingAction?, + question: AppleCompanionQuestionPayload? = nil, + sessions: [AppleCompanionSessionPreview] = [], + updatedAt: Date = Date() + ) { + self.version = version + self.sequence = sequence + self.sessionId = sessionId + self.source = source + self.status = status + self.toolName = toolName + self.workspaceName = workspaceName + self.messages = messages + self.pendingAction = pendingAction + self.question = question + self.sessions = sessions + self.updatedAt = updatedAt + } + + private enum CodingKeys: String, CodingKey { + case version + case sequence + case sessionId + case source + case status + case toolName + case workspaceName + case messages + case pendingAction + case question + case sessions + case updatedAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decode(Int.self, forKey: .version) + sequence = try container.decode(UInt64.self, forKey: .sequence) + sessionId = try container.decodeIfPresent(String.self, forKey: .sessionId) + source = try container.decode(String.self, forKey: .source) + status = try container.decode(AppleCompanionStatus.self, forKey: .status) + toolName = try container.decodeIfPresent(String.self, forKey: .toolName) + workspaceName = try container.decodeIfPresent(String.self, forKey: .workspaceName) + messages = try container.decode([AppleCompanionMessagePreview].self, forKey: .messages) + pendingAction = try container.decodeIfPresent(AppleCompanionPendingAction.self, forKey: .pendingAction) + question = try container.decodeIfPresent(AppleCompanionQuestionPayload.self, forKey: .question) + sessions = try container.decodeIfPresent([AppleCompanionSessionPreview].self, forKey: .sessions) ?? [] + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } +} + +public enum AppleCompanionCommandType: String, Codable, Equatable, Sendable { + case requestCurrentState + case approveCurrentPermission + case denyCurrentPermission + case skipCurrentQuestion + case answerQuestion + case focus +} + +public struct AppleCompanionCommandPayload: Codable, Equatable, Sendable { + public let version: Int + public let type: AppleCompanionCommandType + public let sessionId: String? + public let source: String? + public let answer: String? + + public init(version: Int = 1, type: AppleCompanionCommandType, sessionId: String? = nil, source: String? = nil, answer: String? = nil) { + self.version = version + self.type = type + self.sessionId = sessionId + self.source = source + self.answer = answer + } +} diff --git a/Tests/CodeIslandCoreTests/AppleCompanionPayloadTests.swift b/Tests/CodeIslandCoreTests/AppleCompanionPayloadTests.swift new file mode 100644 index 0000000..571aa1e --- /dev/null +++ b/Tests/CodeIslandCoreTests/AppleCompanionPayloadTests.swift @@ -0,0 +1,133 @@ +import XCTest +@testable import CodeIslandCore + +final class AppleCompanionPayloadTests: XCTestCase { + + func testStatePayloadRoundTripsQuestionDetails() throws { + let updatedAt = Date(timeIntervalSince1970: 1_777_171_200) + let payload = AppleCompanionStatePayload( + sequence: 7, + sessionId: "session-1", + source: "codex", + status: .waitingQuestion, + toolName: "AskUserQuestion", + workspaceName: "CodeIsland", + messages: [ + AppleCompanionMessagePreview(role: .user, text: "帮我生成一篇长篇小说") + ], + pendingAction: .question, + question: AppleCompanionQuestionPayload( + header: "小说类型", + question: "你想看什么类型的小说?", + options: ["都市/现实", "科幻/未来"], + descriptions: ["现代都市背景、职场、情感、生活故事", "未来世界、人工智能、太空探索、时间旅行"], + index: 1, + total: 4, + allowsMultipleSelection: false + ), + updatedAt: updatedAt + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(AppleCompanionStatePayload.self, from: data) + + XCTAssertEqual(decoded, payload) + XCTAssertEqual(decoded.question?.header, "小说类型") + XCTAssertEqual(decoded.pendingAction, .question) + } + + func testOlderStatePayloadWithoutQuestionStillDecodes() throws { + let json = """ + { + "version": 1, + "sequence": 8, + "sessionId": "session-2", + "source": "claude", + "status": "idle", + "toolName": null, + "workspaceName": "workspace", + "messages": [], + "pendingAction": null, + "updatedAt": 1777171200 + } + """ + + let decoded = try JSONDecoder().decode(AppleCompanionStatePayload.self, from: Data(json.utf8)) + + XCTAssertEqual(decoded.sequence, 8) + XCTAssertEqual(decoded.source, "claude") + XCTAssertNil(decoded.question) + XCTAssertTrue(decoded.sessions.isEmpty) + } + + func testStatePayloadRoundTripsSessionPreviews() throws { + let updatedAt = Date(timeIntervalSince1970: 1_777_171_230) + let payload = AppleCompanionStatePayload( + sequence: 9, + sessionId: "codex-1", + source: "codex", + status: .processing, + toolName: "Read", + workspaceName: "CodeIsland", + messages: [], + pendingAction: nil, + sessions: [ + AppleCompanionSessionPreview( + sessionId: "codex-1", + source: "codex", + status: .processing, + toolName: "Read", + workspaceName: "CodeIsland", + message: "检查 StandBy 多会话展示", + updatedAt: updatedAt + ), + AppleCompanionSessionPreview( + sessionId: "claude-1", + source: "claude", + status: .waitingQuestion, + toolName: "AskUserQuestion", + workspaceName: "workspace", + message: "你想写什么类型的小说?", + updatedAt: updatedAt + ) + ], + updatedAt: updatedAt + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(AppleCompanionStatePayload.self, from: data) + + XCTAssertEqual(decoded, payload) + XCTAssertEqual(decoded.sessions.count, 2) + XCTAssertEqual(decoded.sessions[1].status, .waitingQuestion) + } + + func testAnswerQuestionCommandCarriesSelectedAnswer() throws { + let command = AppleCompanionCommandPayload( + type: .answerQuestion, + sessionId: "session-3", + source: "codex", + answer: "科幻/未来" + ) + + let data = try JSONEncoder().encode(command) + let decoded = try JSONDecoder().decode(AppleCompanionCommandPayload.self, from: data) + + XCTAssertEqual(decoded.type, .answerQuestion) + XCTAssertEqual(decoded.sessionId, "session-3") + XCTAssertEqual(decoded.source, "codex") + XCTAssertEqual(decoded.answer, "科幻/未来") + } + + func testRequestCurrentStateCommandRoundTrips() throws { + let command = AppleCompanionCommandPayload(type: .requestCurrentState) + + let data = try JSONEncoder().encode(command) + let decoded = try JSONDecoder().decode(AppleCompanionCommandPayload.self, from: data) + + XCTAssertEqual(decoded.type, .requestCurrentState) + XCTAssertNil(decoded.sessionId) + XCTAssertNil(decoded.source) + XCTAssertNil(decoded.answer) + } +} diff --git a/Tests/CodeIslandCoreTests/JSONLTailerTests.swift b/Tests/CodeIslandCoreTests/JSONLTailerTests.swift index 1b4d7af..a864bbe 100644 --- a/Tests/CodeIslandCoreTests/JSONLTailerTests.swift +++ b/Tests/CodeIslandCoreTests/JSONLTailerTests.swift @@ -103,14 +103,12 @@ final class JSONLTailerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: url) } let expectation = self.expectation(description: "delta delivered") - // onDelta fires on the tailer's serial queue; the test reads `captured` - // only after wait(for:), so the access is ordered, not racy. - nonisolated(unsafe) var captured: ConversationTailDelta? + let captured = LockedValue(nil) let tailer = JSONLTailer( queue: DispatchQueue(label: "tailer-test"), onDelta: { delta in - captured = delta + captured.set(delta) expectation.fulfill() } ) @@ -124,8 +122,8 @@ final class JSONLTailerTests: XCTestCase { wait(for: [expectation], timeout: 2) - XCTAssertEqual(captured?.sessionId, "s1") - XCTAssertEqual(captured?.lastAssistantMessage, "ping") + XCTAssertEqual(captured.value?.sessionId, "s1") + XCTAssertEqual(captured.value?.lastAssistantMessage, "ping") tailer.detach(sessionId: "s1") } @@ -158,12 +156,10 @@ final class JSONLTailerTests: XCTestCase { try Data("".utf8).write(to: url) defer { try? FileManager.default.removeItem(at: url) } - // onDelta fires on the tailer's serial queue; reads happen after the - // sleeps below, so this access is ordered, not racy. - nonisolated(unsafe) var callCount = 0 + let callCount = LockedValue(0) let tailer = JSONLTailer( queue: DispatchQueue(label: "tailer-test"), - onDelta: { _ in callCount += 1 } + onDelta: { _ in callCount.update { $0 += 1 } } ) tailer.attach(sessionId: "s1", filePath: url.path) Thread.sleep(forTimeInterval: 0.15) @@ -179,7 +175,7 @@ final class JSONLTailerTests: XCTestCase { try appendToFile(url: url, content: assistantLine(text: "ignored") + "\n") Thread.sleep(forTimeInterval: 0.2) - XCTAssertEqual(callCount, 1) + XCTAssertEqual(callCount.value, 1) } // MARK: - Fixtures @@ -223,3 +219,30 @@ final class JSONLTailerTests: XCTestCase { try handle.close() } } + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + self.storage = value + } + + var value: Value { + lock.lock() + defer { lock.unlock() } + return storage + } + + func set(_ value: Value) { + lock.lock() + storage = value + lock.unlock() + } + + func update(_ body: (inout Value) -> Void) { + lock.lock() + body(&storage) + lock.unlock() + } +} diff --git a/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift b/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift index dfeedea..e7f2044 100644 --- a/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift +++ b/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift @@ -81,6 +81,44 @@ final class AppStatePrimarySourceTests: XCTestCase { "When at least one session is running, surface that source not the user default") } + func testAppleCompanionPayloadIncludesMultipleSessionsByPriority() { + let appState = AppState() + let now = Date() + + var codex = SessionSnapshot() + codex.source = "codex" + codex.status = .processing + codex.currentTool = "Read" + codex.cwd = "/tmp/CodeIsland" + codex.lastActivity = now + codex.addRecentMessage(ChatMessage(isUser: false, text: "正在检查 StandBy 多会话展示")) + appState.sessions["codex-1"] = codex + + var claude = SessionSnapshot() + claude.source = "claude" + claude.status = .waitingQuestion + claude.currentTool = "AskUserQuestion" + claude.cwd = "/tmp/workspace" + claude.lastActivity = now.addingTimeInterval(-3) + claude.addRecentMessage(ChatMessage(isUser: true, text: "你想写什么类型的小说?")) + appState.sessions["claude-1"] = claude + + var gemini = SessionSnapshot() + gemini.source = "gemini" + gemini.status = .idle + gemini.cwd = "/tmp/notes" + gemini.lastActivity = now.addingTimeInterval(-1) + appState.sessions["gemini-1"] = gemini + + appState.refreshDerivedState() + let payload = appState.appleCompanionStatePayload(sequence: 99) + + XCTAssertEqual(payload.sessions.map(\.sessionId), ["claude-1", "codex-1", "gemini-1"]) + XCTAssertEqual(payload.sessions[0].status, .waitingQuestion) + XCTAssertEqual(payload.sessions[1].source, "codex") + XCTAssertEqual(payload.sessions[1].message, "正在检查 StandBy 多会话展示") + } + // MARK: - Buddy (ESP32) frame alignment with island display /// When a session is idle, esp32DisplayFrame must use the user-configured diff --git a/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift b/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift index 2ea3dc7..f712f04 100644 --- a/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift +++ b/Tests/CodeIslandTests/AppStateQuestionFlowTests.swift @@ -305,6 +305,86 @@ final class AppStateQuestionFlowTests: XCTestCase { XCTAssertEqual(appState.questionQueue.count, 1, "Queue should not be drained by direct answerQuestion") } + // MARK: - iPhone Buddy question mirror + + func testAppleCompanionPayloadMirrorsPendingAskUserQuestion() async throws { + let appState = AppState() + let event = try makeAskUserQuestionEvent( + sessionId: "s-companion-payload", + questions: [ + question( + header: "小说类型", + text: "你想看什么类型的小说?", + options: ["都市/现实", "科幻/未来"], + descriptions: ["现代都市背景、职场、情感、生活故事", "未来世界、人工智能、太空探索、时间旅行"] + ), + question(header: "篇幅长度", text: "你希望多长?", options: ["短篇", "长篇"]), + ] + ) + + _ = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + } + + await Task.yield() + + let payload = appState.appleCompanionStatePayload(sequence: 42) + XCTAssertEqual(payload.sequence, 42) + XCTAssertEqual(payload.sessionId, "s-companion-payload") + XCTAssertEqual(payload.status, .waitingQuestion) + XCTAssertEqual(payload.pendingAction, .question) + XCTAssertEqual(payload.toolName, "AskUserQuestion") + + let question = try XCTUnwrap(payload.question) + XCTAssertEqual(question.header, "小说类型") + XCTAssertEqual(question.question, "你想看什么类型的小说?") + XCTAssertEqual(question.options, ["都市/现实", "科幻/未来"]) + XCTAssertEqual(question.descriptions, ["现代都市背景、职场、情感、生活故事", "未来世界、人工智能、太空探索、时间旅行"]) + XCTAssertEqual(question.index, 1) + XCTAssertEqual(question.total, 2) + XCTAssertFalse(question.allowsMultipleSelection) + } + + func testCompanionAnswerAdvancesAskUserQuestionAndCompletes() async throws { + let appState = AppState() + let event = try makeAskUserQuestionEvent( + sessionId: "s-companion-answer", + questions: [ + question(header: "工作模式", text: "你希望我接下来以哪种方式协作?", options: ["直接执行", "先给方案"]), + question(header: "输出风格", text: "你更喜欢我用哪种回答风格?", options: ["极简", "平衡"]), + ] + ) + + let responseTask = Task { + await withCheckedContinuation { continuation in + appState.handleAskUserQuestion(event, continuation: continuation) + } + } + + await Task.yield() + + appState.answerCompanionQuestion("直接执行") + let secondPayload = appState.appleCompanionStatePayload(sequence: 43) + let secondQuestion = try XCTUnwrap(secondPayload.question) + XCTAssertEqual(secondQuestion.header, "输出风格") + XCTAssertEqual(secondQuestion.index, 2) + XCTAssertEqual(secondQuestion.total, 2) + XCTAssertEqual(appState.questionQueue.count, 1) + + appState.answerCompanionQuestion("平衡") + let responseData = await responseTask.value + let answers = try extractAnswers(from: responseData) + XCTAssertEqual(answers["你希望我接下来以哪种方式协作?"] as? String, "直接执行") + XCTAssertEqual(answers["你更喜欢我用哪种回答风格?"] as? String, "平衡") + XCTAssertEqual(appState.questionQueue.count, 0) + + let completedPayload = appState.appleCompanionStatePayload(sequence: 44) + XCTAssertNil(completedPayload.question) + XCTAssertNil(completedPayload.pendingAction) + } + // MARK: - Helpers private func makeAskUserQuestionEvent(sessionId: String, questions: [[String: Any]]) throws -> HookEvent { @@ -342,10 +422,12 @@ final class AppStateQuestionFlowTests: XCTestCase { return event } - private func question(header: String?, text: String, options: [String]) -> [String: Any] { + private func question(header: String?, text: String, options: [String], descriptions: [String]? = nil) -> [String: Any] { var result: [String: Any] = [ "question": text, - "options": options.map { ["label": $0, "description": ""] } + "options": options.enumerated().map { index, option in + ["label": option, "description": descriptions?[safe: index] ?? ""] + } ] if let header { result["header"] = header @@ -372,3 +454,9 @@ final class AppStateQuestionFlowTests: XCTestCase { return try XCTUnwrap(decision["behavior"] as? String) } } + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/apple-companion/APP_REVIEW_NOTES.md b/apple-companion/APP_REVIEW_NOTES.md new file mode 100644 index 0000000..59ad079 --- /dev/null +++ b/apple-companion/APP_REVIEW_NOTES.md @@ -0,0 +1,51 @@ +# App Review Notes + +You can paste this into App Store Connect's Review Notes field and adjust the contact details before submission. + +```text +Code Island is an iPhone and Apple Watch Buddy for the Code Island Mac app. + +This submission is version 1.0.0. The app mirrors the current AI agent status from the user's Mac to iPhone Dynamic Island, Lock Screen, StandBy, and Apple Watch. It uses local network discovery and Bluetooth only for communication between the user's own devices. + +Review without a Mac: +1. Launch Code Island on iPhone. +2. Tap "进入演示模式" on the first screen. +3. The app will show a realistic demo session with agent state, recent messages, and a question prompt. +4. Tap "开启实时活动" to start the Live Activity and inspect Dynamic Island / Lock Screen presentation. +5. If an Apple Watch is paired, launch the watchOS app. It will receive the latest demo state from the iPhone app. +6. Tap "切换演示状态" in the iPhone app to cycle through question, processing, interrupted, and idle examples. + +Review with a Mac: +1. Run the matching Code Island Mac app from this build. +2. Open Code Island Settings -> Buddy. +3. Enable iPhone Buddy broadcasting. +4. Keep iPhone and Mac on the same local network. +5. Launch the iPhone app and select the discovered Mac. + +No account is required. No external server is required. No personal data is collected by the developer. +``` + +Chinese version for reference: + +```text +Code Island Buddy 是 Code Island Mac 应用的 iPhone 与 Apple Watch 端。 + +本次提交版本为 1.0.0。它会把用户 Mac 上当前 AI agent 的状态同步到 iPhone 灵动岛、锁屏、StandBy 和 Apple Watch。本应用只使用本地网络发现和蓝牙在用户自己的设备之间通信。 + +没有 Mac 时的审核方式: +1. 在 iPhone 上打开 Code Island。 +2. 点击首屏的“进入演示模式”。 +3. 应用会展示一组真实风格的演示状态,包括 agent 状态、最近消息和提问。 +4. 点击“开启实时活动”,可以查看 Live Activity、灵动岛和锁屏展示。 +5. 如果配对了 Apple Watch,打开 watchOS app 后会收到 iPhone 上的演示状态。 +6. 点击 iPhone app 内的“切换演示状态”,可以在提问、处理中、中断、空闲等状态之间切换。 + +有 Mac 时的审核方式: +1. 运行同版本的 Code Island Mac app。 +2. 打开 Code Island 设置 -> Buddy。 +3. 开启 iPhone Buddy 广播。 +4. 确保 iPhone 和 Mac 在同一本地网络中。 +5. 打开 iPhone app,选择发现到的 Mac。 + +应用不需要账号,不需要外部服务器,开发者不收集个人数据。 +``` diff --git a/apple-companion/APP_STORE_METADATA.md b/apple-companion/APP_STORE_METADATA.md new file mode 100644 index 0000000..d06f1fc --- /dev/null +++ b/apple-companion/APP_STORE_METADATA.md @@ -0,0 +1,217 @@ +# App Store Connect Metadata + +Use this file as the source text when preparing the first public App Store submission for Code Island. + +## Version + +- Version: `1.0.0` +- Build: `4` +- Platforms: iPhone, Apple Watch +- Bundle ID: `top.fengye.CodeIslandCompanion` +- Category: Developer Tools +- Secondary category: Productivity + +## Localized App Information + +### Simplified Chinese + +Name: + +```text +Code Island Buddy +``` + +Subtitle: + +```text +把 Mac 上的 AI 会话带到 iPhone 和手表 +``` + +Promotional text: + +```text +在 iPhone 灵动岛、锁屏、StandBy 和 Apple Watch 上查看 Code Island 的实时会话状态。 +``` + +Description: + +```text +Code Island Buddy 是 Code Island Mac 应用的 iPhone 与 Apple Watch 端。 + +它会把 Mac 上当前 AI agent 的状态同步到 iPhone、灵动岛、锁屏、StandBy 和 Apple Watch。你可以快速查看当前会话、工作区、工具调用、最近动态,以及需要你回答的问题。 + +适合希望在 Mac 外继续关注 AI 编程会话的开发者:当 agent 正在处理、等待审批、遇到问题或进入空闲状态时,iPhone 和 Apple Watch 都能给你一个轻量的状态窗口。 + +主要功能: +- iPhone 端连接 Mac 上的 Code Island +- 灵动岛和锁屏实时活动展示当前会话 +- StandBy 横屏展示一个或多个会话状态 +- Apple Watch app 和小组件同步最新状态 +- 演示模式可在没有 Mac 的情况下预览完整体验 + +本应用不需要账号,不依赖外部服务器。设备之间的同步发生在你的本地网络和 Apple 设备能力内。 +``` + +Keywords: + +```text +AI,agent,开发者,Mac,灵动岛,StandBy,Apple Watch,效率,编程,Code Island +``` + +What's New: + +```text +首个公开版本。支持 iPhone、灵动岛、锁屏、StandBy、Apple Watch app 和 watchOS 小组件,用于同步 Code Island Mac 端的 AI 会话状态。 +``` + +### English + +Name: + +```text +Code Island Buddy +``` + +Subtitle: + +```text +AI agent status on iPhone and Watch +``` + +Promotional text: + +```text +Follow your Code Island sessions from Dynamic Island, Lock Screen, StandBy, and Apple Watch. +``` + +Description: + +```text +Code Island Buddy brings Code Island Mac sessions to iPhone and Apple Watch. + +It mirrors the current AI agent state from your Mac to iPhone, Dynamic Island, Lock Screen, StandBy, and Apple Watch. You can glance at the active session, workspace, tool activity, recent messages, and questions that need your attention. + +It is built for developers who want a lightweight way to keep an eye on AI coding sessions while away from the Mac. When an agent is processing, waiting for approval, blocked by a question, or idle, your iPhone and Apple Watch can show the latest state. + +Features: +- Connect to Code Island running on your Mac +- Show the current session in Live Activities, Dynamic Island, and Lock Screen +- Present one or more sessions in StandBy +- Sync status to Apple Watch app and watchOS widgets +- Preview the full experience with Demo Mode when a Mac is not available + +No account is required. No external server is required. Device sync happens through your local network and Apple device capabilities. +``` + +Keywords: + +```text +AI,agent,developer,Mac,Dynamic Island,StandBy,Apple Watch,productivity,coding,Code Island +``` + +What's New: + +```text +Initial public release. Adds iPhone, Dynamic Island, Lock Screen, StandBy, Apple Watch app, and watchOS widget support for mirroring Code Island Mac agent sessions. +``` + +## URLs + +Support URL: + +```text +https://github.com/wxtsky/CodeIsland +``` + +Marketing URL: + +```text +https://github.com/wxtsky/CodeIsland +``` + +Privacy Policy URL: + +```text +https://fengye404.top/code-island-buddy/privacy/ +``` + +If the privacy policy is published somewhere else, update this URL before submission. + +## Rights And Rating + +Copyright: + +```text +© 2026 Haonan Xia +``` + +Content Rights: + +```text +No, this app does not contain, show, or access third-party media content. It displays local session status generated by Code Island on the user's own Mac. +``` + +Age Rating: + +```text +4+ +``` + +Suggested age-rating questionnaire answers: + +- Cartoon or fantasy violence: None +- Realistic violence: None +- Sexual content or nudity: None +- Profanity or crude humor: None +- Alcohol, tobacco, or drug references: None +- Mature or suggestive themes: None +- Horror or fear themes: None +- Medical or treatment information: None +- Gambling: None +- Unrestricted web access: No +- User-generated content: No +- Contests: No + +## App Privacy + +Use this as the intended App Privacy answer set unless the binary changes before submission. + +- Tracking: No +- Data collection: No data collected by the developer +- Third-party advertising: No +- Account creation: No +- Analytics SDKs: No +- Crash reporting SDKs: No +- Backend service operated by developer: No + +Notes: + +- Local Network is used to discover and communicate with the user's own Mac running Code Island. +- Bluetooth may be used as a local Buddy signal between the user's own devices. +- WatchConnectivity is used to sync the latest state from iPhone to Apple Watch. +- Session status is displayed on the user's devices and is not sent to the developer. + +## Export Compliance + +The app does not implement custom cryptography. It uses Apple-provided local networking, Bluetooth, WatchConnectivity, ActivityKit, and platform security APIs. + +When App Store Connect asks export compliance questions, answer based on Apple's current wording and the final binary. For this build, the intended answer is that the app does not use non-exempt custom encryption. + +## Review Contact + +Fill these in inside App Store Connect before submission: + +- First name: +- Last name: +- Phone: +- Email: + +## Demo Account + +No account is required. + +## Review Notes + +Paste the English review notes from `apple-companion/APP_REVIEW_NOTES.md`. + +Important: include Demo Mode instructions so Apple can review without the Mac Buddy. diff --git a/apple-companion/APP_STORE_RELEASE.md b/apple-companion/APP_STORE_RELEASE.md new file mode 100644 index 0000000..7c37637 --- /dev/null +++ b/apple-companion/APP_STORE_RELEASE.md @@ -0,0 +1,88 @@ +# Code Island App Store Release Checklist + +This checklist covers the iPhone, Live Activity, Dynamic Island, StandBy, Apple Watch app, and watchOS widget Buddy for Code Island. + +Current public submission target: + +- Version: `1.0.0` +- Build: `4` +- Primary working directory: `/Users/fengye/workspace/CodeIsland` +- Xcode project: `ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj` + +## Before Uploading a Build + +1. Confirm Apple Developer signing is selected for every target: + - `CodeIslandCompanion` + - `CodeIslandCompanionWidget` + - `CodeIslandWatchApp` + - `CodeIslandWatchWidget` + +2. Confirm the app can be reviewed without a Mac: + - Launch the iPhone app. + - Tap `进入演示模式`. + - Tap `开启实时活动` to show the Live Activity / Dynamic Island preview. + - Open the Apple Watch app and confirm it receives the demo state. + +3. Confirm the real Mac path still works: + - Run the matching Code Island Mac build from this branch. + - Open Code Island Settings -> Buddy. + - Enable iPhone Buddy broadcasting. + - Connect from iPhone and verify state updates. + +4. Run local verification: + +```bash +scripts/check-companion-ui-regressions.sh +scripts/smoke-companion-ui.sh +scripts/smoke-companion-watch-ui.sh +swift test --filter AppleCompanionPayloadTests +``` + +5. Archive from Xcode: + - Select `Any iOS Device (arm64)` or a connected iPhone. + - Choose `Product -> Archive`. + - In Organizer, choose `Distribute App -> App Store Connect -> Upload`. + +## App Store Connect Metadata + +Use `apple-companion/APP_STORE_METADATA.md` as the source of truth for name, subtitle, description, keywords, URLs, privacy answers, export compliance notes, and "What's New" text. + +Privacy policy: + +Publish `apple-companion/PRIVACY_POLICY.md` somewhere public, for example GitHub Pages, and use that public URL in App Store Connect. The proposed URL is listed in `apple-companion/APP_STORE_METADATA.md`. + +## App Privacy Answers + +Current intended privacy posture: + +- Data collection: no data collected by the developer. +- Tracking: no tracking. +- Third-party advertising: none. +- Account creation: none. +- Local network: used only to discover and communicate with the user's own Mac running Code Island. +- Bluetooth: used only as a lightweight local Buddy signal between the user's own devices. + +If new analytics, crash reporting, cloud sync, or third-party SDKs are added later, update these answers before submission. + +## Export Compliance + +The app uses Apple's local networking and platform security APIs. It does not implement custom encryption. When App Store Connect asks export compliance questions, answer based on the final binary and Apple's current wording. If the app only uses standard Apple-provided encryption, it usually falls under the platform-provided encryption path rather than a custom cryptography product. + +## Review Notes + +Use the template in `apple-companion/APP_REVIEW_NOTES.md`. + +Important: tell reviewers about `进入演示模式`, because reviewers may not have the matching Mac Buddy available. + +## Final Manual Test Matrix + +| Area | Test | +| --- | --- | +| iPhone app | Launch, enter demo mode, switch demo state, exit demo mode | +| Local network | Connect to Mac, receive idle/running/question/interrupted state | +| Live Activity | Start, update, stop, verify Dynamic Island and Lock Screen | +| Background | Put iPhone app in background, trigger Mac state update, verify Live Activity update if system schedules it | +| Watch app | Install, launch, receive state from iPhone, scroll status/message/actions/activity pages | +| Watch widget | Add widget / Smart Stack item, verify latest state appears | +| Permissions | Local Network, Bluetooth, Notifications | +| Failure modes | Mac not found, Mac disconnected, iPhone app relaunched, Watch launched before iPhone sync | diff --git a/apple-companion/DEVICE_TESTING.md b/apple-companion/DEVICE_TESTING.md new file mode 100644 index 0000000..83caaef --- /dev/null +++ b/apple-companion/DEVICE_TESTING.md @@ -0,0 +1,358 @@ +# Code Island Buddy 真机测试流程 + +这份流程用于验证 Code Island 的 iPhone、Live Activity / Dynamic Island / StandBy、Apple Watch app 和 watchOS widget 在真实设备上的表现。 + +## 测试目标 + +- Mac 端 Code Island 能被 iPhone 发现并连接。 +- Mac 当前 agent 状态能同步到 iPhone app。 +- iPhone 开启实时活动后,锁屏、灵动岛、StandBy 能显示当前状态。 +- iPhone 进入后台后一段时间,仍能通过轻量蓝牙摘要刷新实时活动。 +- Apple Watch 能从 iPhone 同步状态、最近动态和问题状态。 +- 断连、重连、锁屏、后台等边界状态不出现明显卡死或错误 UI。 + +## 设备与环境 + +| 项目 | 要求 | +| --- | --- | +| Mac | 已安装当前分支构建出的 CodeIsland Mac app | +| iPhone | 已通过 USB 连接到 Mac,并在 Xcode 中信任/配对 | +| Apple Watch | 已与这台 iPhone 配对,调试时已信任 Mac | +| 网络 | Mac 和 iPhone 在同一个 Wi-Fi;蓝牙开启 | +| 权限 | iPhone 允许本地网络、蓝牙、通知;Watch 允许通知 | +| Xcode | 顶部设备选择器能看到 iPhone;需要测 Watch 时能看到 Apple Watch | + +## 先跑本地检查 + +在真机前先确认代码本身是干净的: + +```bash +swift test -c release +scripts/check-companion-ui-regressions.sh +scripts/smoke-companion-ui.sh +scripts/smoke-companion-watch-ui.sh +xcodebuild -project ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj \ + -scheme CodeIslandCompanion \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath .build/CompanionUITestDerivedData \ + test +xcodebuild -project ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj \ + -scheme CodeIslandCompanion \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + CODE_SIGNING_ALLOWED=NO build +xcodebuild -project ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj \ + -scheme CodeIslandWatchApp \ + -configuration Release \ + -destination 'generic/platform=watchOS' \ + CODE_SIGNING_ALLOWED=NO build +``` + +通过标准: + +- 所有命令通过。 +- `xcodebuild` 输出里没有 `error:`。 +- 没有新增需要处理的 `warning:`。 + +上面的 UI Test 会用模拟状态自动覆盖 iPhone app 的提问态、长消息滚动和空闲态。它不依赖 Mac 连接,主要用来提前发现按钮消失、卡片不可访问、最近动态不可滚动这类回归。 + +如果要把同一套 UI Test 跑到真机,把 destination 改成 iPhone 的设备 ID: + +```bash +xcodebuild -project ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj \ + -scheme CodeIslandCompanion \ + -configuration Debug \ + -destination 'platform=iOS,id=<你的 iPhone 设备 ID>' \ + -derivedDataPath .build/CompanionDeviceUITestDerivedData \ + test +``` + +真机 UI Test 需要 iPhone 解锁、屏幕保持亮起,并且 Xcode 调试通道稳定。它能自动操作 app 内部 UI,但不能代替人工确认系统级弹窗、灵动岛外观和锁屏展示。 + +## 1. 安装 iPhone App + +1. 打开 `ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj`。 +2. 顶部 scheme 选择 `CodeIslandCompanion`。 +3. 顶部设备选择你的 iPhone。 +4. 确认 `Signing & Capabilities` 中这几个 target 都使用你的 Developer Team: + - `CodeIslandCompanion` + - `CodeIslandCompanionWidget` + - `CodeIslandWatchApp` + - `CodeIslandWatchWidget` +5. 点击 Run。 +6. iPhone 首次启动时允许: + - 本地网络 + - 蓝牙 + - 通知 + +通过标准: + +- iPhone 桌面出现 `Code Island`。 +- app 可以打开,不弹出开发者证书未信任错误。 +- app 首屏能看到发现 Mac 或等待连接状态。 + +失败排查: + +- 证书未信任:iPhone 进入 `设置 -> 通用 -> VPN 与设备管理`,信任 `Apple Development: ...`。 +- 安装失败:删除 iPhone 上旧版 `Code Island` 后重新 Run。 +- 找不到设备:拔插 USB,解锁 iPhone,确认 Xcode `Window -> Devices and Simulators` 中 iPhone 是 connected。 + +## 2. Mac 端广播 + +1. 启动当前分支构建出的 CodeIsland Mac app。 +2. 打开 CodeIsland 设置。 +3. 进入 `Buddy`。 +4. 打开 iPhone Buddy 广播。 +5. 确认状态显示为等待或已连接。 + +通过标准: + +- iPhone app 能发现这台 Mac。 +- Mac 设置页中 iPhone Buddy 状态能从未连接变成连接中或已连接。 + +失败排查: + +- iPhone 和 Mac 不在同一 Wi-Fi 时,Multipeer 发现可能失败。 +- macOS 防火墙或网络权限异常时,先关闭再打开 iPhone Buddy 广播。 +- iPhone 端本地网络权限被拒绝时,进入 iPhone `设置 -> Code Island -> 本地网络` 打开。 + +## 3. iPhone 前台同步 + +1. iPhone 打开 `Code Island`,选择发现到的 Mac。 +2. Mac 上打开一个 Codex / Claude / Gemini 等会话。 +3. 在会话里发送一句测试消息,例如:`真机同步测试`。 +4. 观察 iPhone app 首页。 + +通过标准: + +- iPhone 显示当前 agent 名称。 +- 小人图标和 Mac 端对应角色一致。 +- 状态能在空闲、处理中、等待回答、问题等状态之间变化。 +- 最近动态出现用户消息和助手消息。 +- 工具调用、工作区、问题卡片没有明显截断或英文裸露。 + +建议记录: + +- agent 名称: +- 工作区: +- 当前状态: +- 最近动态是否出现: +- 是否有 UI 截断: + +## 4. Live Activity / Dynamic Island / 锁屏 + +1. 保持 iPhone app 已连接 Mac。 +2. 在 iPhone app 点击 `开启实时活动`。 +3. 回到桌面,观察灵动岛紧凑态。 +4. 长按灵动岛,观察展开态。 +5. 锁屏,观察锁屏实时活动。 +6. 横放充电进入 StandBy,观察 StandBy 展示。 + +通过标准: + +- 灵动岛紧凑态至少显示角色图标和当前状态提示。 +- 灵动岛展开态不被系统 UI 遮挡,问题、工作区、状态不互相覆盖。 +- 锁屏通知/实时活动显示角色、状态、当前消息摘要。 +- StandBy 没有上下大块异常空白,核心信息完整可读。 +- 切换 Mac 端状态后,实时活动能跟随更新。 + +失败排查: + +- 灵动岛没有出现:确认没有其他 app 的 Live Activity 长时间占用;也可以先停止再重新开启实时活动。 +- 锁屏不显示:确认通知权限允许,并在 iPhone `设置 -> 面容 ID 与密码 -> 锁定时允许访问` 中允许实时活动。 +- StandBy 不出现:确认 iPhone 横放、锁屏、充电,且系统已启用 StandBy。 + +## 5. iPhone 后台接收测试 + +这是最关键的真机验收项,用来验证轻量后台方案是否有效。 + +1. iPhone app 前台连接 Mac。 +2. 点击 `开启实时活动`。 +3. 回到 iPhone 桌面,不要从多任务界面划掉 app。 +4. 锁屏等待 1 分钟。 +5. 在 Mac 上向当前会话发送一条新消息。 +6. 观察锁屏/灵动岛是否更新。 +7. 继续等待到 5 分钟,再发送第二条消息。 +8. 继续等待到 10 分钟,再发送第三条消息。 + +通过标准: + +- 1 分钟后台后,灵动岛或锁屏能收到新状态。 +- 5 分钟后台后,至少能通过蓝牙摘要刷新角色、状态或消息摘要。 +- 10 分钟后台后,如果系统没有唤醒 iPhone app,实时活动仍保持最后一次有效状态,不应显示错误或崩溃。 +- 重新打开 iPhone app 后,完整状态能恢复为 Mac 当前状态。 + +建议记录: + +| 时间点 | 操作 | iPhone 是否更新 | Watch 是否更新 | 备注 | +| --- | --- | --- | --- | --- | +| 前台 | Mac 发消息 | | | | +| 后台 1 分钟 | Mac 发消息 | | | | +| 后台 5 分钟 | Mac 发消息 | | | | +| 后台 10 分钟 | Mac 发消息 | | | | +| 重新打开 app | 回前台 | | | | + +重要边界: + +- 不测试用户从多任务界面强杀 app 的情况。iOS 不保证强杀后还能后台接收。 +- 不依赖 APNs,也不需要后端,所以后台表现会受 iOS 蓝牙和 Live Activity 调度策略影响。 + +## 6. Apple Watch 安装与同步 + +### 方式 A:随 iPhone app 安装 + +1. iPhone 已安装 `Code Island`。 +2. 打开 iPhone 自带 `Watch` app。 +3. 在 `我的手表 -> 可用 App` 中找到 `Code Island`。 +4. 点击安装。 +5. 在 Apple Watch 上打开 `Code Island`。 + +### 方式 B:Xcode 直接调试 + +1. Xcode 顶部 scheme 选择 `CodeIslandWatchApp`。 +2. 顶部设备选择你的 Apple Watch。 +3. 点击 Run。 + +通过标准: + +- Watch app 能启动。 +- iPhone 已连接 Mac 时,Watch 能显示同一个 agent 状态。 +- Watch 页面左右/上下切换时,每屏只聚焦一个重点信息。 +- 小人图标大小合适,和 Mac/iPhone 角色一致。 +- 最近动态、问题、操作页都能进入。 + +失败排查: + +- Watch 一直等待 iPhone:先打开 iPhone app,确认它已连接 Mac;再重新打开 Watch app。 +- Watch 安装失败:重启 Watch 和 iPhone,确认 Watch 已解锁并信任 Mac。 +- Xcode 看不到 Watch:打开 `Window -> Devices and Simulators`,确认 iPhone 和 Watch 都 connected。 + +## 7. Watch 后台与通知 + +1. iPhone app 前台连接 Mac,并开启实时活动。 +2. Watch 打开 `Code Island`,确认已同步。 +3. 按数码表冠回表盘。 +4. 在 Mac 上触发新状态: + - 普通消息 + - 等待用户回答 + - 处理中 + - 中断或错误 +5. 观察 Watch 是否收到状态变化或通知。 + +通过标准: + +- Watch app 重新打开时能看到最新 iPhone 状态。 +- 等待回答/需要注意的状态应有明显提示。 +- 页面切换或关键状态变化有合适的触感反馈。 +- watchOS widget / Smart Stack 能显示最近一次状态摘要。 + +备注: + +- WatchConnectivity 不是实时 socket。Watch 在后台时,系统可能延迟投递;重新打开 Watch app 时应立即追上最新状态。 +- 需要强实时提醒的状态,后续应优先用通知或 complication/widget timeline 表达,而不是假设 Watch app 常驻后台。 + +## 8. 断连与恢复 + +### iPhone 断开 Mac + +1. iPhone 已连接 Mac。 +2. 在 Mac CodeIsland 设置里关闭 iPhone Buddy 广播。 +3. 等 10 秒。 +4. 重新打开广播。 + +通过标准: + +- iPhone 不崩溃。 +- UI 能显示离线/等待或保持最后有效状态。 +- 广播恢复后可以重新连接。 + +### 网络变化 + +1. iPhone app 已连接。 +2. 关闭 iPhone Wi-Fi,等待 10 秒。 +3. 重新打开 Wi-Fi。 +4. 必要时点 iPhone app 内重新连接。 + +通过标准: + +- app 不崩溃。 +- 重新连接后状态恢复。 +- Live Activity 不出现明显错误文案。 + +### 蓝牙变化 + +1. iPhone app 已连接并开启实时活动。 +2. 关闭 iPhone 蓝牙 10 秒。 +3. 重新打开蓝牙。 +4. 后台发送新消息验证恢复。 + +通过标准: + +- 蓝牙关闭时不会崩溃。 +- 蓝牙恢复后,后台摘要通道可以继续刷新。 + +## 9. 提问与操作链路 + +1. 在 Mac 端触发一个 `askUserQuestion` 或等待回答状态。 +2. iPhone app 应展示问题卡片和选项。 +3. 在 iPhone 选择一个选项。 +4. 回到 Mac 会话,确认选择被送回。 +5. Watch 打开同一状态,确认问题页能展示问题。 + +通过标准: + +- iPhone 问题文案为中文,不出现 `AskUserQuestion` 裸露在主标题里。 +- 选项可读、可滑动、不被截断。 +- 选择后 Mac 端收到正确答案。 +- Watch 能显示问题概要;复杂回答可以引导到 iPhone。 + +## 10. 最终发布前截图 + +建议每次发布前保存这些截图: + +- iPhone 发现 Mac +- iPhone 空闲 +- iPhone 处理中 +- iPhone 等待回答 +- iPhone 最近动态 +- Dynamic Island 紧凑态 +- Dynamic Island 展开态 +- 锁屏实时活动 +- StandBy +- Watch 状态页 +- Watch 消息页 +- Watch 操作页 +- Watch 最近动态页 +- Watch widget / Smart Stack + +截图保存到: + +```text +apple-companion/images/ +``` + +## 11. 验收结论模板 + +```text +测试日期: +Mac 版本: +iPhone 型号 / iOS: +Apple Watch 型号 / watchOS: +Xcode 版本: + +Mac -> iPhone 前台同步:通过 / 不通过 +iPhone Live Activity:通过 / 不通过 +iPhone 后台 1 分钟:通过 / 不通过 +iPhone 后台 5 分钟:通过 / 不通过 +iPhone 后台 10 分钟:通过 / 不通过 +iPhone -> Watch 同步:通过 / 不通过 +Watch 通知 / 触感:通过 / 不通过 +断线重连:通过 / 不通过 +提问回答链路:通过 / 不通过 + +遗留问题: +1. +2. +3. +``` diff --git a/apple-companion/PRIVACY_POLICY.md b/apple-companion/PRIVACY_POLICY.md new file mode 100644 index 0000000..47858ad --- /dev/null +++ b/apple-companion/PRIVACY_POLICY.md @@ -0,0 +1,56 @@ +# Code Island Privacy Policy + +Effective date: 2026-05-30 + +Code Island is designed as a local Buddy for the Code Island Mac app. It mirrors the current session status from your Mac to your iPhone, Live Activity, Dynamic Island, StandBy, and Apple Watch. + +## Data Collection + +The developer does not collect personal data from this app. + +The app does not use third-party analytics, advertising SDKs, tracking SDKs, or remote logging services. + +## Local Device Communication + +Code Island uses local network and Bluetooth capabilities to communicate between devices you own: + +- Local network discovery is used to find your Mac running Code Island. +- MultipeerConnectivity is used to exchange session status and control commands between your iPhone and Mac. +- Bluetooth may be used as a lightweight local signal for Buddy status updates. +- WatchConnectivity is used to sync the latest state from your iPhone to your Apple Watch. + +This communication happens locally between your devices. The developer does not operate a backend service for this Buddy app. + +## Data Shown in the App + +The app may display local session information from Code Island, such as: + +- Current agent name and status +- Workspace or session label +- Recent message previews +- Tool or permission state +- Questions that need your attention + +This information is used to render the Buddy experience on your devices. It is not sent to the developer. + +## Notifications and Live Activities + +If you enable notifications or Live Activities, the app may show current session status on your Lock Screen, Dynamic Island, StandBy, or Apple Watch. These surfaces are provided by Apple system features on your own devices. + +## No Tracking + +Code Island does not track you across apps or websites. + +## Children + +Code Island is a developer productivity Buddy and is not directed to children. + +## Changes + +This policy may be updated as the app changes. Material changes should be reflected in the published policy before submitting a new App Store version. + +## Contact + +For questions, open an issue at: + +https://github.com/fengye404/CodeIsland/issues diff --git a/apple-companion/README.md b/apple-companion/README.md new file mode 100644 index 0000000..3efc95f --- /dev/null +++ b/apple-companion/README.md @@ -0,0 +1,214 @@ +# Code Island Buddy + +Code Island Buddy 是 Code Island 的 Apple 设备端,包括 iPhone、Dynamic Island / StandBy、Apple Watch app 和 watchOS widget。 + +它的目标不是做一个新的聊天客户端,而是把 Mac 上 CodeIsland 当前看到的 agent 状态同步到随身设备:你可以在 iPhone 灵动岛和 Apple Watch 上看当前会话、工具调用、最近动态,并在需要时回到 Mac 继续处理。 + +> [!NOTE] +> 当前代码仍然放在 `ios/CodeIslandCompanion/` Xcode 工程里。这个目录是 Code Island Buddy 的产品说明与截图目录,方便像 `android-watch/` 一样单独阅读和展示。 + +## 代码位置 + +| 路径 | 作用 | +| --- | --- | +| `ios/CodeIslandCompanion/CodeIslandCompanion/` | iPhone app 主体 | +| `ios/CodeIslandCompanion/CodeIslandCompanionWidget/` | iPhone Live Activity、Dynamic Island、StandBy UI | +| `ios/CodeIslandCompanion/CodeIslandWatchApp/` | Apple Watch app | +| `ios/CodeIslandCompanion/CodeIslandWatchWidget/` | watchOS Widget / Smart Stack 展示 | +| `ios/CodeIslandCompanion/Shared/` | ActivityKit、Watch、iPhone 共用模型与 mascot 视图 | +| `ios/CodeIslandCompanion/project.yml` | XcodeGen 工程定义 | +| `Sources/CodeIsland/AppleCompanionPublisher.swift` | Mac 端 iPhone Buddy 状态发布 | +| `Sources/CodeIsland/AppleCompanionBluetoothPeripheral.swift` | Mac 端 BLE 后台摘要通道 | +| `Sources/CodeIslandCore/AppleCompanionPayload.swift` | Mac / iPhone / Watch 共用协议模型 | +| `scripts/smoke-companion-ui.sh` | iPhone UI smoke 截图测试 | +| `scripts/smoke-companion-watch-ui.sh` | Apple Watch UI smoke 截图测试 | + +## 发布资料 + +| 路径 | 作用 | +| --- | --- | +| `apple-companion/APP_STORE_RELEASE.md` | App Store / TestFlight 发布清单 | +| `apple-companion/APP_STORE_METADATA.md` | App Store Connect 可复制的名称、描述、关键词、隐私与审核材料 | +| `apple-companion/APP_REVIEW_NOTES.md` | 可粘贴到 App Store Connect 的审核说明 | +| `apple-companion/PRIVACY_POLICY.md` | 隐私政策草稿,发布前需要放到公开 URL | +| `apple-companion/DEVICE_TESTING.md` | iPhone / Apple Watch 真机测试流程 | + +## 当前能力 + +- iPhone 通过本地网络发现 Mac 上的 CodeIsland。 +- iPhone 通过 MultipeerConnectivity 接收完整状态并发送操作命令。 +- iPhone 通过 CoreBluetooth 接收轻量状态摘要,辅助后台 Live Activity 更新。 +- Live Activity 展示 Dynamic Island、锁屏、StandBy 形态。 +- Apple Watch 通过 WatchConnectivity 同步 iPhone 当前状态。 +- Apple Watch 可查看状态、问题、最近动态,并提供回到 Mac 的入口。 + +## 通信方案 + +当前方案刻意保持轻量,不依赖 APNs,也不需要部署后端。 + +```text +Mac CodeIsland + ├─ MultipeerConnectivity:前台完整状态、最近动态、操作命令 + └─ CoreBluetooth BLE:后台轻量状态摘要 + +iPhone Code Island + ├─ Live Activity / Dynamic Island / StandBy:展示当前状态 + └─ WatchConnectivity:把 iPhone 当前状态同步到 Apple Watch + +Apple Watch + ├─ Watch app:查看当前 agent、问题、最近动态和操作入口 + └─ Smart Stack widget:展示轻量状态 +``` + +### 为什么同时有 Multipeer 和 BLE + +MultipeerConnectivity 适合局域网内传完整状态,连接快、实现简单,也方便从 iPhone 给 Mac 发送操作命令。但 iOS app 进入后台后,MultipeerConnectivity 不能作为可靠的长期后台通道:系统可能暂停浏览、广播和会话收发。 + +BLE 通道只传轻量摘要,例如 agent、状态、当前消息、工作区和少量最近动态。它的目标是让 iPhone 在后台或锁屏时仍有机会被系统唤醒,刷新 Live Activity,并继续把最新状态交给 Apple Watch。它不是一个常驻后台进程,也不保证每一条事件都实时抵达,但比只依赖 Multipeer 更符合 iOS 的后台调度模型。 + +### Live Activity 的角色 + +Live Activity、Dynamic Island 和 StandBy 只负责展示状态。它们不会自己连接 Mac,也不能自己长期接收网络事件。状态更新必须来自 iPhone app 本身,或者通过 ActivityKit push。当前方案不使用 APNs,所以后台更新主要依赖 BLE 摘要通道和 iOS 允许的后台唤醒窗口。 + +## 使用方式 + +1. 在 Mac 上运行这个 fork 里的 CodeIsland。 +2. 打开 CodeIsland 设置,进入 `Buddy`,开启 iPhone Buddy 广播。 +3. 在 Xcode 打开 `ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj`。 +4. 在 `Signing & Capabilities` 里给 iPhone app、Widget、Watch app、Watch Widget 都选择你的 Apple Developer Team。 +5. 选择 `CodeIslandCompanion` scheme 和你的 iPhone,点击 Run。 +6. iPhone 首次启动时允许本地网络、蓝牙、通知权限。 +7. 在 iPhone 里选择发现到的 Mac,连接后状态会自动同步。 +8. 如果有配对 Apple Watch,iPhone app 安装后 Watch app 会作为随附 app 可安装;调试时也可以在 Xcode 选择 `CodeIslandWatchApp` scheme 直接跑到 Watch。 + +## 演示模式 + +iPhone app 首屏提供 `进入演示模式`。它用于 App Store 审核、截图和没有 Mac 时的快速预览: + +1. 打开 iPhone app。 +2. 点击 `进入演示模式`。 +3. 点击 `开启实时活动` 可以预览锁屏、灵动岛和 StandBy。 +4. 打开 Apple Watch app,会收到 iPhone 当前演示状态。 +5. 点击 `切换演示状态` 可以在提问、处理中、中断、空闲等状态之间切换。 + +演示模式不会连接外部服务器,也不会发送任何真实会话数据。 + +## 产品截图 + +### iPhone App + + + + + + + + + + + + + + + + + + + + + + +
发现 Mac空闲状态等待提问
iPhone discovery screeniPhone idle screeniPhone question screen
长消息请求中断处理状态
iPhone long message screeniPhone interrupted screeniPhone processing Dynamic Island
+ +### Dynamic Island + + + + + + + + + + +
等待回答处理中
Dynamic Island waiting for answerDynamic Island processing
+ +### StandBy + + + + + + + + +
横置长消息
StandBy long message view
+ +### Apple Watch 40mm + + + + + + + + + + + + + + + + + + +
状态消息 / 提问
Apple Watch 40mm status pageApple Watch 40mm message page
操作最近动态
Apple Watch 40mm actions pageApple Watch 40mm activity page
+ +### Apple Watch 46mm + + + + + + + + + + + + + + + + + + +
状态消息 / 提问
Apple Watch 46mm status pageApple Watch 46mm message page
操作最近动态
Apple Watch 46mm actions pageApple Watch 46mm activity page
+ +## 本地验证 + +```bash +# iPhone 页面 smoke 截图 +scripts/smoke-companion-ui.sh + +# Watch 页面 smoke 截图 +scripts/smoke-companion-watch-ui.sh + +# 协议和 UI 回归检查 +scripts/check-companion-ui-regressions.sh + +# Swift 单元测试 +swift test +``` + +截图会输出到 `.build/`。如果要更新本文档中的产品图,可以先跑 smoke 脚本,再把对应 PNG 覆盖到 `apple-companion/images/`。 + +## 目前边界 + +- 不依赖 APNs,也不需要部署后端。 +- MultipeerConnectivity 主要用于前台完整同步;iPhone 进入后台较久后,不能保证继续收到完整消息。 +- iPhone 被用户从多任务界面强制杀掉后,系统不会保证继续接收事件。 +- Live Activity 和 BLE 后台接收受 iOS 调度策略影响,适合作为轻量 Buddy 能力,不等价于常驻后台进程。 +- StandBy 是否持续亮屏由 iOS、机型、Always-On Display、低电量模式和睡眠专注决定,app 不能强制保持屏幕常亮。 +- Watch 真机震动、通知触达和后台同步仍需要真机验收;模拟器主要用于构建、布局和页面状态验证。 diff --git a/apple-companion/images/iphone-discovery.png b/apple-companion/images/iphone-discovery.png new file mode 100644 index 0000000..94b59d4 Binary files /dev/null and b/apple-companion/images/iphone-discovery.png differ diff --git a/apple-companion/images/iphone-dynamic-island-processing.png b/apple-companion/images/iphone-dynamic-island-processing.png new file mode 100644 index 0000000..466518b Binary files /dev/null and b/apple-companion/images/iphone-dynamic-island-processing.png differ diff --git a/apple-companion/images/iphone-dynamic-island-question.png b/apple-companion/images/iphone-dynamic-island-question.png new file mode 100644 index 0000000..8ac8e10 Binary files /dev/null and b/apple-companion/images/iphone-dynamic-island-question.png differ diff --git a/apple-companion/images/iphone-idle.png b/apple-companion/images/iphone-idle.png new file mode 100644 index 0000000..56f80a8 Binary files /dev/null and b/apple-companion/images/iphone-idle.png differ diff --git a/apple-companion/images/iphone-interrupted.png b/apple-companion/images/iphone-interrupted.png new file mode 100644 index 0000000..f8170b5 Binary files /dev/null and b/apple-companion/images/iphone-interrupted.png differ diff --git a/apple-companion/images/iphone-long-message.png b/apple-companion/images/iphone-long-message.png new file mode 100644 index 0000000..3ccd5e1 Binary files /dev/null and b/apple-companion/images/iphone-long-message.png differ diff --git a/apple-companion/images/iphone-question.png b/apple-companion/images/iphone-question.png new file mode 100644 index 0000000..d505511 Binary files /dev/null and b/apple-companion/images/iphone-question.png differ diff --git a/apple-companion/images/iphone-standby-long-message.png b/apple-companion/images/iphone-standby-long-message.png new file mode 100644 index 0000000..f8404b6 Binary files /dev/null and b/apple-companion/images/iphone-standby-long-message.png differ diff --git a/apple-companion/images/watch-40-actions.png b/apple-companion/images/watch-40-actions.png new file mode 100644 index 0000000..83d0e3a Binary files /dev/null and b/apple-companion/images/watch-40-actions.png differ diff --git a/apple-companion/images/watch-40-activity.png b/apple-companion/images/watch-40-activity.png new file mode 100644 index 0000000..86fc481 Binary files /dev/null and b/apple-companion/images/watch-40-activity.png differ diff --git a/apple-companion/images/watch-40-message.png b/apple-companion/images/watch-40-message.png new file mode 100644 index 0000000..46b63dd Binary files /dev/null and b/apple-companion/images/watch-40-message.png differ diff --git a/apple-companion/images/watch-40-status.png b/apple-companion/images/watch-40-status.png new file mode 100644 index 0000000..a301616 Binary files /dev/null and b/apple-companion/images/watch-40-status.png differ diff --git a/apple-companion/images/watch-46-actions.png b/apple-companion/images/watch-46-actions.png new file mode 100644 index 0000000..ca4a25f Binary files /dev/null and b/apple-companion/images/watch-46-actions.png differ diff --git a/apple-companion/images/watch-46-activity.png b/apple-companion/images/watch-46-activity.png new file mode 100644 index 0000000..3c5f6fc Binary files /dev/null and b/apple-companion/images/watch-46-activity.png differ diff --git a/apple-companion/images/watch-46-message.png b/apple-companion/images/watch-46-message.png new file mode 100644 index 0000000..1d9f5e9 Binary files /dev/null and b/apple-companion/images/watch-46-message.png differ diff --git a/apple-companion/images/watch-46-status.png b/apple-companion/images/watch-46-status.png new file mode 100644 index 0000000..b253b6a Binary files /dev/null and b/apple-companion/images/watch-46-status.png differ diff --git a/build.sh b/build.sh index aca2b8d..b70273d 100755 --- a/build.sh +++ b/build.sh @@ -182,15 +182,30 @@ build_mac() { echo "Creating DMG..." DMG_PATH="$BUILD_DIR/$APP_NAME.dmg" rm -f "$DMG_PATH" - create-dmg \ - --volname "$APP_NAME" \ - --window-pos 200 120 \ - --window-size 600 400 \ - --icon-size 100 \ - --icon "$APP_NAME.app" 150 185 \ - --app-drop-link 450 185 \ - --no-internet-enable \ - "$DMG_PATH" "$APP_BUNDLE" + if command -v create-dmg >/dev/null 2>&1; then + create-dmg \ + --volname "$APP_NAME" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "$APP_NAME.app" 150 185 \ + --app-drop-link 450 185 \ + --no-internet-enable \ + "$DMG_PATH" "$APP_BUNDLE" + else + echo "create-dmg not found, using hdiutil fallback..." + DMG_STAGING="$BUILD_DIR/dmg-staging" + rm -rf "$DMG_STAGING" + mkdir -p "$DMG_STAGING" + ditto "$APP_BUNDLE" "$DMG_STAGING/$APP_NAME.app" + ln -s /Applications "$DMG_STAGING/Applications" + hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$DMG_STAGING" \ + -format UDZO \ + -ov \ + "$DMG_PATH" + fi codesign --force --sign "$SIGN_ID" "$DMG_PATH" echo "Notarizing DMG..." diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.pbxproj b/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6d325b8 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.pbxproj @@ -0,0 +1,1100 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0190F180CA1516FF19DE1D09 /* CodeIslandActivityState+Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F350CB2292BF84421D9BD4 /* CodeIslandActivityState+Payload.swift */; }; + 08D3B08488A688EBD7DA59B3 /* WatchConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 333CBB203CC901B5FFE12BFD /* WatchConnection.swift */; }; + 136C1DFA4E4F80076A794410 /* QwenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4978BAC1D31033F1CFFA542C /* QwenView.swift */; }; + 17193B5CDD14E270C08D8BA7 /* CompanionDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4708FC8D74184A554015C647 /* CompanionDisplayText.swift */; }; + 1A9B2D28C0B651802CC22E46 /* AntiGravityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312741CE7E44499956C35C45 /* AntiGravityView.swift */; }; + 1AF542E5FAA3BB93A6F11100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B23882298428AF63A26BAE4E /* Assets.xcassets */; }; + 1B9E327493AEBEDF3E0511C6 /* CodeIslandCompanionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8843E069F081B15AE433EA8D /* CodeIslandCompanionApp.swift */; }; + 1CE1FA4AF1409D284488CC98 /* OpenCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F0ECA53BBAD64BA8C9771F /* OpenCodeView.swift */; }; + 2432ED91DABE20F4D4BEE97B /* CursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A637A87231D2C2493FF97990 /* CursorView.swift */; }; + 2A6065473519A66446A555CB /* CopilotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */; }; + 2EF1014B18539D7592DBB8D3 /* KimiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE587DE1B0740F9A5B0B19 /* KimiView.swift */; }; + 3235DBD0E8E40BAF3ABC7CB2 /* AntiGravityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312741CE7E44499956C35C45 /* AntiGravityView.swift */; }; + 34BC4A9556FAE0A7893901BB /* StepFunView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1A12DC31BC8AC80FFB96D8 /* StepFunView.swift */; }; + 360FEB3C9F4DA28284E96288 /* PixelCharacterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6345881A14A90FBB79C7453 /* PixelCharacterView.swift */; }; + 3942DC95ED8F566BE7D6D8B5 /* CodeIslandWatchStatusWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 734B6802145F7FAF30E9548F /* CodeIslandWatchStatusWidget.swift */; }; + 396FBAE8C75D88E0A0DAFAFC /* SharedMascotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FD5E698E776728E6018927 /* SharedMascotView.swift */; }; + 39F79F8253CF95E70563A097 /* WatchStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCDD1EEEF11FA911FA8E42BF /* WatchStateStore.swift */; }; + 3AEB7FB2BC4A5B04E4FE6FEB /* LiveActivityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030876B03E408787BD569C05 /* LiveActivityController.swift */; }; + 3B9EA0EE7B76975935E76A33 /* QoderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5393F6AABCBEEDA6A832553 /* QoderView.swift */; }; + 406E06EA697812F1527C2EBA /* PixelCharacterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6345881A14A90FBB79C7453 /* PixelCharacterView.swift */; }; + 454B79632040B38A8EE556B1 /* TraeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D0B4EC41F3FEC24838746 /* TraeView.swift */; }; + 4E223E65F13F078A4388DFBC /* WatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED3A7210B0F1BDDB7803D970 /* WatchBridge.swift */; }; + 53AAA61D301B8A543539C4B2 /* CopilotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */; }; + 54D41F7821F5757358C1E651 /* BuddyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B07028BAA056A1DA7C7791 /* BuddyView.swift */; }; + 56486A706E0A4FFBF3BE9F34 /* DexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6470A3909D478701A3B07E5 /* DexView.swift */; }; + 56E1107A140CBB68064DACD9 /* GeminiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA2D1F95A933787580CCAA6 /* GeminiView.swift */; }; + 57D3C0DB02580B82815A37DD /* GeminiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA2D1F95A933787580CCAA6 /* GeminiView.swift */; }; + 581D6458B7EF4DDD4D45E4D6 /* BuddyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B07028BAA056A1DA7C7791 /* BuddyView.swift */; }; + 59ABB512113F870A7896E879 /* CompanionDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4708FC8D74184A554015C647 /* CompanionDisplayText.swift */; }; + 5CC36A8128BA65E2481142C1 /* WorkBuddyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F91D3F784C8F89A31E63158 /* WorkBuddyView.swift */; }; + 60EDB1D15F768EF9BEA51D31 /* OpenCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F0ECA53BBAD64BA8C9771F /* OpenCodeView.swift */; }; + 6123D11F09A767D4AFDA5AD3 /* GeminiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA2D1F95A933787580CCAA6 /* GeminiView.swift */; }; + 61B79FD49393B5E3C7BEEF3B /* CodeIslandWatchApp.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = F40F9340746E45E572758656 /* CodeIslandWatchApp.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 61E65895CCEBAD88E0183D08 /* StepFunView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1A12DC31BC8AC80FFB96D8 /* StepFunView.swift */; }; + 632DF62531C30A5489CE9819 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DE41181FECB15FC695E14CCA /* LaunchScreen.storyboard */; }; + 6449EF8FC0C32176FBF40393 /* OpenCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F0ECA53BBAD64BA8C9771F /* OpenCodeView.swift */; }; + 6916681318ECDDB5E09EEA93 /* KimiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE587DE1B0740F9A5B0B19 /* KimiView.swift */; }; + 6C444954B6DA9F0B542304EE /* CursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A637A87231D2C2493FF97990 /* CursorView.swift */; }; + 6DB070CD0CB5259D6341C29F /* StepFunView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1A12DC31BC8AC80FFB96D8 /* StepFunView.swift */; }; + 7211483270726AC25E7F53C1 /* PixelCharacterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6345881A14A90FBB79C7453 /* PixelCharacterView.swift */; }; + 740A096612F9E66F04D7A29C /* WatchStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCDD1EEEF11FA911FA8E42BF /* WatchStateStore.swift */; }; + 756DC3DF126278851C03BD1D /* ClineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96CB90CA9C700A293F0F841 /* ClineView.swift */; }; + 786E71DFAABF2A42048E9091 /* CodeIslandCompanionWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1CD9F5E97A2CDBC4E3F54478 /* CodeIslandCompanionWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 7881FAFAE3BB0A5F0644BED2 /* HermesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE12267AF913F735B14B643A /* HermesView.swift */; }; + 79A66F8039924CB94EEC67AD /* WatchConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FB3EF0A4912EE483D702CF6 /* WatchConnectivity.framework */; }; + 7B84614D6F40BEDB519D5F13 /* WorkBuddyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F91D3F784C8F89A31E63158 /* WorkBuddyView.swift */; }; + 7C6AE08B3264CADC760A0BB6 /* HermesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE12267AF913F735B14B643A /* HermesView.swift */; }; + 7F8B015A60EAD665A97191D6 /* QoderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5393F6AABCBEEDA6A832553 /* QoderView.swift */; }; + 8087A336412F9084936AF405 /* BuddyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B07028BAA056A1DA7C7791 /* BuddyView.swift */; }; + 83A226FBEBA72BBAB62E575D /* DexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6470A3909D478701A3B07E5 /* DexView.swift */; }; + 892225EA1A62EA16B21E2E36 /* CompanionDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4708FC8D74184A554015C647 /* CompanionDisplayText.swift */; }; + 8DDDF81D31DDBF87F1470A97 /* KimiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE587DE1B0740F9A5B0B19 /* KimiView.swift */; }; + 8ED54C8B56E520259B9392E9 /* WatchConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FB3EF0A4912EE483D702CF6 /* WatchConnectivity.framework */; }; + 8FD5A994D566498D0B7F3495 /* QoderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5393F6AABCBEEDA6A832553 /* QoderView.swift */; }; + 96A94DCEFE3EB695E86CA91C /* WorkBuddyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F91D3F784C8F89A31E63158 /* WorkBuddyView.swift */; }; + 977FDE1B19E4DF1F5985EC0D /* QoderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5393F6AABCBEEDA6A832553 /* QoderView.swift */; }; + 98F5D1DDE415B3BAFA0A74F1 /* DroidView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1D4BD6D6F35284006F9354 /* DroidView.swift */; }; + 9ABC3282EABFDAE28BE3F6C3 /* CursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A637A87231D2C2493FF97990 /* CursorView.swift */; }; + 9C43FBF323733C72F963E4EE /* CodeIslandCompanionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78FA9B17D5BB160C8E714F6 /* CodeIslandCompanionUITests.swift */; }; + 9C5B16C59B32BABAAB34FF08 /* DexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6470A3909D478701A3B07E5 /* DexView.swift */; }; + 9D46FD32389C4AEC2A839727 /* TraeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D0B4EC41F3FEC24838746 /* TraeView.swift */; }; + 9F7CB1CDE599F3DF71E5738C /* CompanionDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4708FC8D74184A554015C647 /* CompanionDisplayText.swift */; }; + 9FAB64A752F6142AAD32E5B6 /* DroidView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1D4BD6D6F35284006F9354 /* DroidView.swift */; }; + A19C06D932D5A5FE5DF0D513 /* QwenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4978BAC1D31033F1CFFA542C /* QwenView.swift */; }; + A370B957E19B18B5121C92B4 /* SharedMascotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FD5E698E776728E6018927 /* SharedMascotView.swift */; }; + A52DA811D5FA84CF55DE9E9E /* CodeIslandWatchWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFFE00C7B98F6A46DB0ECA /* CodeIslandWatchWidgetBundle.swift */; }; + A6EAB4F70D77D31647CAE26E /* CursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A637A87231D2C2493FF97990 /* CursorView.swift */; }; + A83B38DDC3F15F01EA589239 /* WatchContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BFECA3B58A8F5B7D80676BF /* WatchContentView.swift */; }; + ABCA1FD581A1C3E2089F9540 /* AntiGravityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312741CE7E44499956C35C45 /* AntiGravityView.swift */; }; + AF508E52E331531A53A2EA2B /* ClineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96CB90CA9C700A293F0F841 /* ClineView.swift */; }; + B6E51EE8DB8BF988DACB1276 /* CopilotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */; }; + B7CB440FDD0903B05E12AA85 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E766978AB60149BF2A008DF4 /* ContentView.swift */; }; + BF2B5114F1EA889DDAE9098D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D85F2D481FDAD5D904A0F3E5 /* Assets.xcassets */; }; + C08F601348AFA5246B72ABD0 /* StepFunView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1A12DC31BC8AC80FFB96D8 /* StepFunView.swift */; }; + C1B00C9DC300515350E75603 /* CompanionMascotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A2C29C635BC87419FDED7F /* CompanionMascotView.swift */; }; + C28E22B3696BD872D48746E4 /* WatchStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCDD1EEEF11FA911FA8E42BF /* WatchStateStore.swift */; }; + C49F2E33380EB15CF8874FC6 /* CompanionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78474E5ED47A37C22FB49F33 /* CompanionModels.swift */; }; + C5A39883D2868CF21FDD3A15 /* CompanionConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05B0003068A33631114C79F /* CompanionConnection.swift */; }; + C5C5F190B737C3D91D20EA75 /* SharedMascotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FD5E698E776728E6018927 /* SharedMascotView.swift */; }; + CAC99F5E6B9B2DDFA3C0E38A /* DroidView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1D4BD6D6F35284006F9354 /* DroidView.swift */; }; + CBD20DAAC19EF402D7083706 /* QwenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4978BAC1D31033F1CFFA542C /* QwenView.swift */; }; + CD4FAD4958AD915D8E759FFB /* OpenCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F0ECA53BBAD64BA8C9771F /* OpenCodeView.swift */; }; + D37C3157D91B8B67E80B095D /* WatchStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCDD1EEEF11FA911FA8E42BF /* WatchStateStore.swift */; }; + D47274A29745CA64E55F1C21 /* CodeIslandActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA23CC7B351415E136CC947 /* CodeIslandActivityAttributes.swift */; }; + D5F811823EDFED9541402681 /* QwenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4978BAC1D31033F1CFFA542C /* QwenView.swift */; }; + D6346705BBF85F63A3C032D8 /* CodeIslandCompanionWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 014DEC37F3B34948F2B630E4 /* CodeIslandCompanionWidgetBundle.swift */; }; + D9069411705F24ADDA7C573F /* WorkBuddyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F91D3F784C8F89A31E63158 /* WorkBuddyView.swift */; }; + D91E181A929AA9ED8ABEE03E /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D6BFE9B5287EE06D2FB3938 /* AppIntents.framework */; }; + DEACE91A83CD52B2B6394CC9 /* DroidView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1D4BD6D6F35284006F9354 /* DroidView.swift */; }; + DECE043D8EAE0EEEA3417557 /* PixelCharacterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6345881A14A90FBB79C7453 /* PixelCharacterView.swift */; }; + DF8181D3224DFA462CB9E0FA /* TraeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D0B4EC41F3FEC24838746 /* TraeView.swift */; }; + E012D38D806F5AC2F22D569F /* SharedMascotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FD5E698E776728E6018927 /* SharedMascotView.swift */; }; + E04C1A6FD543058CDBC1754B /* KimiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE587DE1B0740F9A5B0B19 /* KimiView.swift */; }; + E15456F718660AE320B7F65F /* CodeIslandLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1079D20C61C1AF51C7F24A /* CodeIslandLiveActivityWidget.swift */; }; + E35A6938CFF1C6DA6BBE6B3B /* CompanionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78474E5ED47A37C22FB49F33 /* CompanionModels.swift */; }; + EB02F7CC8391BFB931BE0421 /* CodeIslandWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E248E1F7A7695B40CE083370 /* CodeIslandWatchApp.swift */; }; + EB3D23EDE1100113E8909302 /* ClineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96CB90CA9C700A293F0F841 /* ClineView.swift */; }; + EC22CA87FDA68D8875939E1B /* CodeIslandWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AB36FBACC9BA81390F2EC8BE /* CodeIslandWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + ED41E65AAA673D9FBFD48377 /* CompanionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78474E5ED47A37C22FB49F33 /* CompanionModels.swift */; }; + EF55FCB89771F97175749ECF /* CodeIslandActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA23CC7B351415E136CC947 /* CodeIslandActivityAttributes.swift */; }; + F29B008893CA6F336BE93A42 /* DexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6470A3909D478701A3B07E5 /* DexView.swift */; }; + F2A37C4EDA80546E6307A8D6 /* CompanionBluetoothCentral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 827D708763E7622166DF3F8B /* CompanionBluetoothCentral.swift */; }; + F6A28D1C6855BA337D8076F8 /* HermesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE12267AF913F735B14B643A /* HermesView.swift */; }; + F6BF2F0FBFE41711C6160DFE /* AntiGravityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312741CE7E44499956C35C45 /* AntiGravityView.swift */; }; + F7141403977B0ACAA74FE90C /* ClineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96CB90CA9C700A293F0F841 /* ClineView.swift */; }; + F894E1D744DA83959EB19554 /* CopilotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */; }; + F8E524B7360A360440DD9690 /* GeminiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA2D1F95A933787580CCAA6 /* GeminiView.swift */; }; + FB9FE22A8E8201717A08D945 /* TraeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D0B4EC41F3FEC24838746 /* TraeView.swift */; }; + FC987B58F87CFE9638DBC90E /* HermesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE12267AF913F735B14B643A /* HermesView.swift */; }; + FC9D256D2F2BC3010E633FBF /* BuddyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B07028BAA056A1DA7C7791 /* BuddyView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1E6092132DEF91DC316807BF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C86DC231658B41C7E38FF15A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 98B3A00FDF4C8093790E92A5; + remoteInfo = CodeIslandCompanionWidget; + }; + 77D846086AB64AFE3A38762C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C86DC231658B41C7E38FF15A /* Project object */; + proxyType = 1; + remoteGlobalIDString = F17F6C4FF2B113739CD23B46; + remoteInfo = CodeIslandWatchApp; + }; + BB58FA90F24E52E7796C4B5E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C86DC231658B41C7E38FF15A /* Project object */; + proxyType = 1; + remoteGlobalIDString = F3973306BF558558774D5BD7; + remoteInfo = CodeIslandCompanion; + }; + CC09B6A7376464EE68BAEABC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C86DC231658B41C7E38FF15A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4BABFBE7E8161A80EA5F2EF8; + remoteInfo = CodeIslandWatchWidget; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 4632E0B05E3E6067BC7837CC /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + EC22CA87FDA68D8875939E1B /* CodeIslandWatchWidget.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + 8BEF37F14AECEEF2F1446564 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 786E71DFAABF2A42048E9091 /* CodeIslandCompanionWidget.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + E77D769B4FB69CEFF7B9FD23 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 61B79FD49393B5E3C7BEEF3B /* CodeIslandWatchApp.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 014DEC37F3B34948F2B630E4 /* CodeIslandCompanionWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeIslandCompanionWidgetBundle.swift; sourceTree = ""; }; + 030876B03E408787BD569C05 /* LiveActivityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityController.swift; sourceTree = ""; }; + 0A1079D20C61C1AF51C7F24A /* CodeIslandLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeIslandLiveActivityWidget.swift; sourceTree = ""; }; + 0A7D0B4EC41F3FEC24838746 /* TraeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraeView.swift; sourceTree = ""; }; + 0CD94C0DFD8C9C1E2D0FA2CD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 0E1D4BD6D6F35284006F9354 /* DroidView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DroidView.swift; sourceTree = ""; }; + 18FD5E698E776728E6018927 /* SharedMascotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedMascotView.swift; sourceTree = ""; }; + 1CD9F5E97A2CDBC4E3F54478 /* CodeIslandCompanionWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = CodeIslandCompanionWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 1D6BFE9B5287EE06D2FB3938 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; }; + 23F0ECA53BBAD64BA8C9771F /* OpenCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeView.swift; sourceTree = ""; }; + 312741CE7E44499956C35C45 /* AntiGravityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntiGravityView.swift; sourceTree = ""; }; + 333CBB203CC901B5FFE12BFD /* WatchConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConnection.swift; sourceTree = ""; }; + 3FB3EF0A4912EE483D702CF6 /* WatchConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WatchConnectivity.framework; path = System/Library/Frameworks/WatchConnectivity.framework; sourceTree = SDKROOT; }; + 4708FC8D74184A554015C647 /* CompanionDisplayText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionDisplayText.swift; sourceTree = ""; }; + 479C70CE85849FBC511B7038 /* CodeIslandWatchApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodeIslandWatchApp.entitlements; sourceTree = ""; }; + 4978BAC1D31033F1CFFA542C /* QwenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QwenView.swift; sourceTree = ""; }; + 4BEF873F784144B4F199E878 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 4F91D3F784C8F89A31E63158 /* WorkBuddyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkBuddyView.swift; sourceTree = ""; }; + 53EE587DE1B0740F9A5B0B19 /* KimiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KimiView.swift; sourceTree = ""; }; + 5F8B728EC484A892286E9460 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 6516178F733B86DDF5CD26E0 /* CodeIslandCompanionUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CodeIslandCompanionUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6BFECA3B58A8F5B7D80676BF /* WatchContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchContentView.swift; sourceTree = ""; }; + 734B6802145F7FAF30E9548F /* CodeIslandWatchStatusWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeIslandWatchStatusWidget.swift; sourceTree = ""; }; + 75853758A7901DF914ACEB33 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 78474E5ED47A37C22FB49F33 /* CompanionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionModels.swift; sourceTree = ""; }; + 7CA2D1F95A933787580CCAA6 /* GeminiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiView.swift; sourceTree = ""; }; + 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotView.swift; sourceTree = ""; }; + 827D708763E7622166DF3F8B /* CompanionBluetoothCentral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionBluetoothCentral.swift; sourceTree = ""; }; + 83B07028BAA056A1DA7C7791 /* BuddyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuddyView.swift; sourceTree = ""; }; + 8843E069F081B15AE433EA8D /* CodeIslandCompanionApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeIslandCompanionApp.swift; sourceTree = ""; }; + 9A2B84F4F96272BE5A760B08 /* CodeIslandCompanion.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CodeIslandCompanion.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A637A87231D2C2493FF97990 /* CursorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorView.swift; sourceTree = ""; }; + A6470A3909D478701A3B07E5 /* DexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexView.swift; sourceTree = ""; }; + AB36FBACC9BA81390F2EC8BE /* CodeIslandWatchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodeIslandWatchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + AC142A70E79612F32AF08DF2 /* CodeIslandWatchWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodeIslandWatchWidget.entitlements; sourceTree = ""; }; + B23882298428AF63A26BAE4E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + BCA23CC7B351415E136CC947 /* CodeIslandActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeIslandActivityAttributes.swift; sourceTree = ""; }; + BE12267AF913F735B14B643A /* HermesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesView.swift; sourceTree = ""; }; + C5393F6AABCBEEDA6A832553 /* QoderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QoderView.swift; sourceTree = ""; }; + C6345881A14A90FBB79C7453 /* PixelCharacterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelCharacterView.swift; sourceTree = ""; }; + CABFFE00C7B98F6A46DB0ECA /* CodeIslandWatchWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeIslandWatchWidgetBundle.swift; sourceTree = ""; }; + CCDD1EEEF11FA911FA8E42BF /* WatchStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStateStore.swift; sourceTree = ""; }; + D2A2C29C635BC87419FDED7F /* CompanionMascotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionMascotView.swift; sourceTree = ""; }; + D78FA9B17D5BB160C8E714F6 /* CodeIslandCompanionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeIslandCompanionUITests.swift; sourceTree = ""; }; + D85F2D481FDAD5D904A0F3E5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DE41181FECB15FC695E14CCA /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + E05B0003068A33631114C79F /* CompanionConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionConnection.swift; sourceTree = ""; }; + E248E1F7A7695B40CE083370 /* CodeIslandWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeIslandWatchApp.swift; sourceTree = ""; }; + E766978AB60149BF2A008DF4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + ED3A7210B0F1BDDB7803D970 /* WatchBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBridge.swift; sourceTree = ""; }; + F40F9340746E45E572758656 /* CodeIslandWatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeIslandWatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F96CB90CA9C700A293F0F841 /* ClineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClineView.swift; sourceTree = ""; }; + F9F350CB2292BF84421D9BD4 /* CodeIslandActivityState+Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CodeIslandActivityState+Payload.swift"; sourceTree = ""; }; + FB1A12DC31BC8AC80FFB96D8 /* StepFunView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepFunView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 67AAFB6B7113267B5C61991C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8ED54C8B56E520259B9392E9 /* WatchConnectivity.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BEBD44ADD46D108AA6EE4C5A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D91E181A929AA9ED8ABEE03E /* AppIntents.framework in Frameworks */, + 79A66F8039924CB94EEC67AD /* WatchConnectivity.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0A19C73F7436290AB43A0733 /* CodeIslandCompanionWidget */ = { + isa = PBXGroup; + children = ( + 014DEC37F3B34948F2B630E4 /* CodeIslandCompanionWidgetBundle.swift */, + 0A1079D20C61C1AF51C7F24A /* CodeIslandLiveActivityWidget.swift */, + 5F8B728EC484A892286E9460 /* Info.plist */, + ); + path = CodeIslandCompanionWidget; + sourceTree = ""; + }; + 4B5DC446285A66B2D47E6894 /* Shared */ = { + isa = PBXGroup; + children = ( + BCA23CC7B351415E136CC947 /* CodeIslandActivityAttributes.swift */, + 4708FC8D74184A554015C647 /* CompanionDisplayText.swift */, + 18FD5E698E776728E6018927 /* SharedMascotView.swift */, + CCDD1EEEF11FA911FA8E42BF /* WatchStateStore.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 591F9D78369A6A5807FCBD6A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1D6BFE9B5287EE06D2FB3938 /* AppIntents.framework */, + 3FB3EF0A4912EE483D702CF6 /* WatchConnectivity.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 8190E5C61FF4340B8909CD4F /* CodeIslandCompanion */ = { + isa = PBXGroup; + children = ( + D85F2D481FDAD5D904A0F3E5 /* Assets.xcassets */, + F9F350CB2292BF84421D9BD4 /* CodeIslandActivityState+Payload.swift */, + 8843E069F081B15AE433EA8D /* CodeIslandCompanionApp.swift */, + 827D708763E7622166DF3F8B /* CompanionBluetoothCentral.swift */, + E05B0003068A33631114C79F /* CompanionConnection.swift */, + D2A2C29C635BC87419FDED7F /* CompanionMascotView.swift */, + 78474E5ED47A37C22FB49F33 /* CompanionModels.swift */, + E766978AB60149BF2A008DF4 /* ContentView.swift */, + 0CD94C0DFD8C9C1E2D0FA2CD /* Info.plist */, + DE41181FECB15FC695E14CCA /* LaunchScreen.storyboard */, + 030876B03E408787BD569C05 /* LiveActivityController.swift */, + ED3A7210B0F1BDDB7803D970 /* WatchBridge.swift */, + ); + path = CodeIslandCompanion; + sourceTree = ""; + }; + 8CBE327D4E5991FEA8BFCA41 /* CodeIsland */ = { + isa = PBXGroup; + children = ( + 312741CE7E44499956C35C45 /* AntiGravityView.swift */, + 83B07028BAA056A1DA7C7791 /* BuddyView.swift */, + F96CB90CA9C700A293F0F841 /* ClineView.swift */, + 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */, + A637A87231D2C2493FF97990 /* CursorView.swift */, + A6470A3909D478701A3B07E5 /* DexView.swift */, + 0E1D4BD6D6F35284006F9354 /* DroidView.swift */, + 7CA2D1F95A933787580CCAA6 /* GeminiView.swift */, + BE12267AF913F735B14B643A /* HermesView.swift */, + 53EE587DE1B0740F9A5B0B19 /* KimiView.swift */, + 23F0ECA53BBAD64BA8C9771F /* OpenCodeView.swift */, + C6345881A14A90FBB79C7453 /* PixelCharacterView.swift */, + C5393F6AABCBEEDA6A832553 /* QoderView.swift */, + 4978BAC1D31033F1CFFA542C /* QwenView.swift */, + FB1A12DC31BC8AC80FFB96D8 /* StepFunView.swift */, + 0A7D0B4EC41F3FEC24838746 /* TraeView.swift */, + 4F91D3F784C8F89A31E63158 /* WorkBuddyView.swift */, + ); + name = CodeIsland; + path = ../../Sources/CodeIsland; + sourceTree = ""; + }; + B7C74B58379C162633B0AB71 /* Products */ = { + isa = PBXGroup; + children = ( + 9A2B84F4F96272BE5A760B08 /* CodeIslandCompanion.app */, + 6516178F733B86DDF5CD26E0 /* CodeIslandCompanionUITests.xctest */, + 1CD9F5E97A2CDBC4E3F54478 /* CodeIslandCompanionWidget.appex */, + F40F9340746E45E572758656 /* CodeIslandWatchApp.app */, + AB36FBACC9BA81390F2EC8BE /* CodeIslandWatchWidget.appex */, + ); + name = Products; + sourceTree = ""; + }; + BBFFC1A4861C007150572AB1 /* CodeIslandWatchWidget */ = { + isa = PBXGroup; + children = ( + 734B6802145F7FAF30E9548F /* CodeIslandWatchStatusWidget.swift */, + AC142A70E79612F32AF08DF2 /* CodeIslandWatchWidget.entitlements */, + CABFFE00C7B98F6A46DB0ECA /* CodeIslandWatchWidgetBundle.swift */, + 75853758A7901DF914ACEB33 /* Info.plist */, + ); + path = CodeIslandWatchWidget; + sourceTree = ""; + }; + D1C2C4106702CD8D64FF8467 /* CodeIslandWatchApp */ = { + isa = PBXGroup; + children = ( + B23882298428AF63A26BAE4E /* Assets.xcassets */, + 479C70CE85849FBC511B7038 /* CodeIslandWatchApp.entitlements */, + E248E1F7A7695B40CE083370 /* CodeIslandWatchApp.swift */, + 4BEF873F784144B4F199E878 /* Info.plist */, + 333CBB203CC901B5FFE12BFD /* WatchConnection.swift */, + 6BFECA3B58A8F5B7D80676BF /* WatchContentView.swift */, + ); + path = CodeIslandWatchApp; + sourceTree = ""; + }; + D46B227CDC7966021E3773E0 /* CodeIslandCompanionUITests */ = { + isa = PBXGroup; + children = ( + D78FA9B17D5BB160C8E714F6 /* CodeIslandCompanionUITests.swift */, + ); + path = CodeIslandCompanionUITests; + sourceTree = ""; + }; + D4F4B4DFF32FD06A20C575CA = { + isa = PBXGroup; + children = ( + 8CBE327D4E5991FEA8BFCA41 /* CodeIsland */, + 8190E5C61FF4340B8909CD4F /* CodeIslandCompanion */, + D46B227CDC7966021E3773E0 /* CodeIslandCompanionUITests */, + 0A19C73F7436290AB43A0733 /* CodeIslandCompanionWidget */, + D1C2C4106702CD8D64FF8467 /* CodeIslandWatchApp */, + BBFFC1A4861C007150572AB1 /* CodeIslandWatchWidget */, + 4B5DC446285A66B2D47E6894 /* Shared */, + 591F9D78369A6A5807FCBD6A /* Frameworks */, + B7C74B58379C162633B0AB71 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4BABFBE7E8161A80EA5F2EF8 /* CodeIslandWatchWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6EC8AA2EC70B9F71655FCE14 /* Build configuration list for PBXNativeTarget "CodeIslandWatchWidget" */; + buildPhases = ( + 296F4DE5CBBAABDA9D12F08E /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CodeIslandWatchWidget; + packageProductDependencies = ( + ); + productName = CodeIslandWatchWidget; + productReference = AB36FBACC9BA81390F2EC8BE /* CodeIslandWatchWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 98B3A00FDF4C8093790E92A5 /* CodeIslandCompanionWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B1AD1C2E22A23BD4CDC2ACF /* Build configuration list for PBXNativeTarget "CodeIslandCompanionWidget" */; + buildPhases = ( + 36420257A13FEAE65757B716 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CodeIslandCompanionWidget; + packageProductDependencies = ( + ); + productName = CodeIslandCompanionWidget; + productReference = 1CD9F5E97A2CDBC4E3F54478 /* CodeIslandCompanionWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; + B4BEC668A900808C83219452 /* CodeIslandCompanionUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A4CCE8D20BB83E3C61AE9D46 /* Build configuration list for PBXNativeTarget "CodeIslandCompanionUITests" */; + buildPhases = ( + 81C079406B4905958C6BCFAA /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 40E50F3A6C92EA7E62C7AF56 /* PBXTargetDependency */, + ); + name = CodeIslandCompanionUITests; + packageProductDependencies = ( + ); + productName = CodeIslandCompanionUITests; + productReference = 6516178F733B86DDF5CD26E0 /* CodeIslandCompanionUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + F17F6C4FF2B113739CD23B46 /* CodeIslandWatchApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09A0F4469E3BEFC56AEA65DC /* Build configuration list for PBXNativeTarget "CodeIslandWatchApp" */; + buildPhases = ( + FC0EB1D4F9723FF36FD94260 /* Sources */, + 118627E995B948F0A6FFAE99 /* Resources */, + 67AAFB6B7113267B5C61991C /* Frameworks */, + 4632E0B05E3E6067BC7837CC /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + C5148171F25E135F3AB966AA /* PBXTargetDependency */, + ); + name = CodeIslandWatchApp; + packageProductDependencies = ( + ); + productName = CodeIslandWatchApp; + productReference = F40F9340746E45E572758656 /* CodeIslandWatchApp.app */; + productType = "com.apple.product-type.application"; + }; + F3973306BF558558774D5BD7 /* CodeIslandCompanion */ = { + isa = PBXNativeTarget; + buildConfigurationList = B1E48881EB1FEA099CD166C8 /* Build configuration list for PBXNativeTarget "CodeIslandCompanion" */; + buildPhases = ( + 26EBA7CC09FF3A74C1057A7A /* Sources */, + AB5C2542C8920112ACD66589 /* Resources */, + BEBD44ADD46D108AA6EE4C5A /* Frameworks */, + 8BEF37F14AECEEF2F1446564 /* Embed Foundation Extensions */, + E77D769B4FB69CEFF7B9FD23 /* Embed Watch Content */, + ); + buildRules = ( + ); + dependencies = ( + A2BC3F7D6ACCF66CA492DBC4 /* PBXTargetDependency */, + 52309DAC5B9D99045DD350BC /* PBXTargetDependency */, + ); + name = CodeIslandCompanion; + packageProductDependencies = ( + ); + productName = CodeIslandCompanion; + productReference = 9A2B84F4F96272BE5A760B08 /* CodeIslandCompanion.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C86DC231658B41C7E38FF15A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 4BABFBE7E8161A80EA5F2EF8 = { + DevelopmentTeam = 6SLN844AVD; + ProvisioningStyle = Automatic; + }; + 98B3A00FDF4C8093790E92A5 = { + DevelopmentTeam = 6SLN844AVD; + ProvisioningStyle = Automatic; + }; + B4BEC668A900808C83219452 = { + DevelopmentTeam = 6SLN844AVD; + ProvisioningStyle = Automatic; + TestTargetID = F3973306BF558558774D5BD7; + }; + F17F6C4FF2B113739CD23B46 = { + DevelopmentTeam = 6SLN844AVD; + ProvisioningStyle = Automatic; + }; + F3973306BF558558774D5BD7 = { + DevelopmentTeam = 6SLN844AVD; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 617E42F1DB082A010DA4EA86 /* Build configuration list for PBXProject "CodeIslandCompanion" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = D4F4B4DFF32FD06A20C575CA; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = B7C74B58379C162633B0AB71 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F3973306BF558558774D5BD7 /* CodeIslandCompanion */, + B4BEC668A900808C83219452 /* CodeIslandCompanionUITests */, + 98B3A00FDF4C8093790E92A5 /* CodeIslandCompanionWidget */, + F17F6C4FF2B113739CD23B46 /* CodeIslandWatchApp */, + 4BABFBE7E8161A80EA5F2EF8 /* CodeIslandWatchWidget */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 118627E995B948F0A6FFAE99 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1AF542E5FAA3BB93A6F11100 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AB5C2542C8920112ACD66589 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BF2B5114F1EA889DDAE9098D /* Assets.xcassets in Resources */, + 632DF62531C30A5489CE9819 /* LaunchScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 26EBA7CC09FF3A74C1057A7A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F6BF2F0FBFE41711C6160DFE /* AntiGravityView.swift in Sources */, + FC9D256D2F2BC3010E633FBF /* BuddyView.swift in Sources */, + AF508E52E331531A53A2EA2B /* ClineView.swift in Sources */, + D47274A29745CA64E55F1C21 /* CodeIslandActivityAttributes.swift in Sources */, + 0190F180CA1516FF19DE1D09 /* CodeIslandActivityState+Payload.swift in Sources */, + 1B9E327493AEBEDF3E0511C6 /* CodeIslandCompanionApp.swift in Sources */, + F2A37C4EDA80546E6307A8D6 /* CompanionBluetoothCentral.swift in Sources */, + C5A39883D2868CF21FDD3A15 /* CompanionConnection.swift in Sources */, + 9F7CB1CDE599F3DF71E5738C /* CompanionDisplayText.swift in Sources */, + C1B00C9DC300515350E75603 /* CompanionMascotView.swift in Sources */, + ED41E65AAA673D9FBFD48377 /* CompanionModels.swift in Sources */, + B7CB440FDD0903B05E12AA85 /* ContentView.swift in Sources */, + B6E51EE8DB8BF988DACB1276 /* CopilotView.swift in Sources */, + 6C444954B6DA9F0B542304EE /* CursorView.swift in Sources */, + 56486A706E0A4FFBF3BE9F34 /* DexView.swift in Sources */, + 9FAB64A752F6142AAD32E5B6 /* DroidView.swift in Sources */, + 57D3C0DB02580B82815A37DD /* GeminiView.swift in Sources */, + F6A28D1C6855BA337D8076F8 /* HermesView.swift in Sources */, + E04C1A6FD543058CDBC1754B /* KimiView.swift in Sources */, + 3AEB7FB2BC4A5B04E4FE6FEB /* LiveActivityController.swift in Sources */, + 60EDB1D15F768EF9BEA51D31 /* OpenCodeView.swift in Sources */, + 360FEB3C9F4DA28284E96288 /* PixelCharacterView.swift in Sources */, + 977FDE1B19E4DF1F5985EC0D /* QoderView.swift in Sources */, + A19C06D932D5A5FE5DF0D513 /* QwenView.swift in Sources */, + 396FBAE8C75D88E0A0DAFAFC /* SharedMascotView.swift in Sources */, + 6DB070CD0CB5259D6341C29F /* StepFunView.swift in Sources */, + 454B79632040B38A8EE556B1 /* TraeView.swift in Sources */, + 4E223E65F13F078A4388DFBC /* WatchBridge.swift in Sources */, + C28E22B3696BD872D48746E4 /* WatchStateStore.swift in Sources */, + 5CC36A8128BA65E2481142C1 /* WorkBuddyView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 296F4DE5CBBAABDA9D12F08E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ABCA1FD581A1C3E2089F9540 /* AntiGravityView.swift in Sources */, + 581D6458B7EF4DDD4D45E4D6 /* BuddyView.swift in Sources */, + F7141403977B0ACAA74FE90C /* ClineView.swift in Sources */, + 3942DC95ED8F566BE7D6D8B5 /* CodeIslandWatchStatusWidget.swift in Sources */, + A52DA811D5FA84CF55DE9E9E /* CodeIslandWatchWidgetBundle.swift in Sources */, + 59ABB512113F870A7896E879 /* CompanionDisplayText.swift in Sources */, + E35A6938CFF1C6DA6BBE6B3B /* CompanionModels.swift in Sources */, + 2A6065473519A66446A555CB /* CopilotView.swift in Sources */, + 2432ED91DABE20F4D4BEE97B /* CursorView.swift in Sources */, + 83A226FBEBA72BBAB62E575D /* DexView.swift in Sources */, + CAC99F5E6B9B2DDFA3C0E38A /* DroidView.swift in Sources */, + F8E524B7360A360440DD9690 /* GeminiView.swift in Sources */, + 7881FAFAE3BB0A5F0644BED2 /* HermesView.swift in Sources */, + 6916681318ECDDB5E09EEA93 /* KimiView.swift in Sources */, + 6449EF8FC0C32176FBF40393 /* OpenCodeView.swift in Sources */, + 7211483270726AC25E7F53C1 /* PixelCharacterView.swift in Sources */, + 8FD5A994D566498D0B7F3495 /* QoderView.swift in Sources */, + CBD20DAAC19EF402D7083706 /* QwenView.swift in Sources */, + C5C5F190B737C3D91D20EA75 /* SharedMascotView.swift in Sources */, + C08F601348AFA5246B72ABD0 /* StepFunView.swift in Sources */, + FB9FE22A8E8201717A08D945 /* TraeView.swift in Sources */, + 740A096612F9E66F04D7A29C /* WatchStateStore.swift in Sources */, + D9069411705F24ADDA7C573F /* WorkBuddyView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 36420257A13FEAE65757B716 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A9B2D28C0B651802CC22E46 /* AntiGravityView.swift in Sources */, + 8087A336412F9084936AF405 /* BuddyView.swift in Sources */, + EB3D23EDE1100113E8909302 /* ClineView.swift in Sources */, + EF55FCB89771F97175749ECF /* CodeIslandActivityAttributes.swift in Sources */, + D6346705BBF85F63A3C032D8 /* CodeIslandCompanionWidgetBundle.swift in Sources */, + E15456F718660AE320B7F65F /* CodeIslandLiveActivityWidget.swift in Sources */, + 892225EA1A62EA16B21E2E36 /* CompanionDisplayText.swift in Sources */, + F894E1D744DA83959EB19554 /* CopilotView.swift in Sources */, + A6EAB4F70D77D31647CAE26E /* CursorView.swift in Sources */, + F29B008893CA6F336BE93A42 /* DexView.swift in Sources */, + DEACE91A83CD52B2B6394CC9 /* DroidView.swift in Sources */, + 6123D11F09A767D4AFDA5AD3 /* GeminiView.swift in Sources */, + FC987B58F87CFE9638DBC90E /* HermesView.swift in Sources */, + 8DDDF81D31DDBF87F1470A97 /* KimiView.swift in Sources */, + CD4FAD4958AD915D8E759FFB /* OpenCodeView.swift in Sources */, + DECE043D8EAE0EEEA3417557 /* PixelCharacterView.swift in Sources */, + 7F8B015A60EAD665A97191D6 /* QoderView.swift in Sources */, + 136C1DFA4E4F80076A794410 /* QwenView.swift in Sources */, + A370B957E19B18B5121C92B4 /* SharedMascotView.swift in Sources */, + 61E65895CCEBAD88E0183D08 /* StepFunView.swift in Sources */, + DF8181D3224DFA462CB9E0FA /* TraeView.swift in Sources */, + D37C3157D91B8B67E80B095D /* WatchStateStore.swift in Sources */, + 7B84614D6F40BEDB519D5F13 /* WorkBuddyView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 81C079406B4905958C6BCFAA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9C43FBF323733C72F963E4EE /* CodeIslandCompanionUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FC0EB1D4F9723FF36FD94260 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3235DBD0E8E40BAF3ABC7CB2 /* AntiGravityView.swift in Sources */, + 54D41F7821F5757358C1E651 /* BuddyView.swift in Sources */, + 756DC3DF126278851C03BD1D /* ClineView.swift in Sources */, + EB02F7CC8391BFB931BE0421 /* CodeIslandWatchApp.swift in Sources */, + 17193B5CDD14E270C08D8BA7 /* CompanionDisplayText.swift in Sources */, + C49F2E33380EB15CF8874FC6 /* CompanionModels.swift in Sources */, + 53AAA61D301B8A543539C4B2 /* CopilotView.swift in Sources */, + 9ABC3282EABFDAE28BE3F6C3 /* CursorView.swift in Sources */, + 9C5B16C59B32BABAAB34FF08 /* DexView.swift in Sources */, + 98F5D1DDE415B3BAFA0A74F1 /* DroidView.swift in Sources */, + 56E1107A140CBB68064DACD9 /* GeminiView.swift in Sources */, + 7C6AE08B3264CADC760A0BB6 /* HermesView.swift in Sources */, + 2EF1014B18539D7592DBB8D3 /* KimiView.swift in Sources */, + 1CE1FA4AF1409D284488CC98 /* OpenCodeView.swift in Sources */, + 406E06EA697812F1527C2EBA /* PixelCharacterView.swift in Sources */, + 3B9EA0EE7B76975935E76A33 /* QoderView.swift in Sources */, + D5F811823EDFED9541402681 /* QwenView.swift in Sources */, + E012D38D806F5AC2F22D569F /* SharedMascotView.swift in Sources */, + 34BC4A9556FAE0A7893901BB /* StepFunView.swift in Sources */, + 9D46FD32389C4AEC2A839727 /* TraeView.swift in Sources */, + 08D3B08488A688EBD7DA59B3 /* WatchConnection.swift in Sources */, + A83B38DDC3F15F01EA589239 /* WatchContentView.swift in Sources */, + 39F79F8253CF95E70563A097 /* WatchStateStore.swift in Sources */, + 96A94DCEFE3EB695E86CA91C /* WorkBuddyView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 40E50F3A6C92EA7E62C7AF56 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F3973306BF558558774D5BD7 /* CodeIslandCompanion */; + targetProxy = BB58FA90F24E52E7796C4B5E /* PBXContainerItemProxy */; + }; + 52309DAC5B9D99045DD350BC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F17F6C4FF2B113739CD23B46 /* CodeIslandWatchApp */; + targetProxy = 77D846086AB64AFE3A38762C /* PBXContainerItemProxy */; + }; + A2BC3F7D6ACCF66CA492DBC4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 98B3A00FDF4C8093790E92A5 /* CodeIslandCompanionWidget */; + targetProxy = 1E6092132DEF91DC316807BF /* PBXContainerItemProxy */; + }; + C5148171F25E135F3AB966AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4BABFBE7E8161A80EA5F2EF8 /* CodeIslandWatchWidget */; + targetProxy = CC09B6A7376464EE68BAEABC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 08C809767E51DD6F2FDD2E2C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 4; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 6SLN844AVD; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + 32F870500936896BCE33A6B3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + CODE_SIGN_ENTITLEMENTS = CodeIslandWatchApp/CodeIslandWatchApp.entitlements; + INFOPLIST_FILE = CodeIslandWatchApp/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanion.watchkitapp; + SDKROOT = watchos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 4C0F87C6E1B001694C287EB6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 4; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 6SLN844AVD; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 50DD7B81379051DB778216CF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + INFOPLIST_FILE = CodeIslandCompanionWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanion.Widget; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 55EBFD3975F5103806518CCF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + CODE_SIGN_ENTITLEMENTS = CodeIslandWatchWidget/CodeIslandWatchWidget.entitlements; + INFOPLIST_FILE = CodeIslandWatchWidget/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanion.watchkitapp.Widget; + SDKROOT = watchos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 5CF993E2112406641DF63393 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanionUITests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = CodeIslandCompanion; + }; + name = Release; + }; + 610D461328F4CA8DEE857634 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanionUITests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = CodeIslandCompanion; + }; + name = Debug; + }; + 74603115470E26336E6FEFCB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = CodeIslandCompanion/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanion; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 80B3AA9D57C795C40172E18C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + CODE_SIGN_ENTITLEMENTS = CodeIslandWatchApp/CodeIslandWatchApp.entitlements; + INFOPLIST_FILE = CodeIslandWatchApp/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanion.watchkitapp; + SDKROOT = watchos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + A502926522AD1379473E3AAC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + INFOPLIST_FILE = CodeIslandCompanionWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanion.Widget; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + DFE49D5E64392DB384649B7A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = CodeIslandCompanion/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanion; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + E612F79F1B59A696697956B6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + CODE_SIGN_ENTITLEMENTS = CodeIslandWatchWidget/CodeIslandWatchWidget.entitlements; + INFOPLIST_FILE = CodeIslandWatchWidget/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = top.fengye.CodeIslandCompanion.watchkitapp.Widget; + SDKROOT = watchos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 09A0F4469E3BEFC56AEA65DC /* Build configuration list for PBXNativeTarget "CodeIslandWatchApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 32F870500936896BCE33A6B3 /* Debug */, + 80B3AA9D57C795C40172E18C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 617E42F1DB082A010DA4EA86 /* Build configuration list for PBXProject "CodeIslandCompanion" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4C0F87C6E1B001694C287EB6 /* Debug */, + 08C809767E51DD6F2FDD2E2C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 6EC8AA2EC70B9F71655FCE14 /* Build configuration list for PBXNativeTarget "CodeIslandWatchWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 55EBFD3975F5103806518CCF /* Debug */, + E612F79F1B59A696697956B6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 8B1AD1C2E22A23BD4CDC2ACF /* Build configuration list for PBXNativeTarget "CodeIslandCompanionWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50DD7B81379051DB778216CF /* Debug */, + A502926522AD1379473E3AAC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + A4CCE8D20BB83E3C61AE9D46 /* Build configuration list for PBXNativeTarget "CodeIslandCompanionUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 610D461328F4CA8DEE857634 /* Debug */, + 5CF993E2112406641DF63393 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + B1E48881EB1FEA099CD166C8 /* Build configuration list for PBXNativeTarget "CodeIslandCompanion" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DFE49D5E64392DB384649B7A /* Debug */, + 74603115470E26336E6FEFCB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = C86DC231658B41C7E38FF15A /* Project object */; +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/xcshareddata/xcschemes/CodeIslandCompanion.xcscheme b/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/xcshareddata/xcschemes/CodeIslandCompanion.xcscheme new file mode 100644 index 0000000..4a26165 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/xcshareddata/xcschemes/CodeIslandCompanion.xcscheme @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f31504c --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "filename" : "Icon-20x20@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-20x20@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "Icon-29x29@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-29x29@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "Icon-40x40@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-40x40@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "Icon-60x60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "Icon-60x60@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "Icon-20x20@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "Icon-20x20@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-29x29@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-29x29@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-40x40@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "Icon-40x40@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-76x76@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "Icon-76x76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "Icon-83.5x83.5@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "Icon-1024x1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024.png new file mode 100644 index 0000000..108cdf2 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@1x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@1x.png new file mode 100644 index 0000000..aa54f97 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@1x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png new file mode 100644 index 0000000..963e0d9 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png new file mode 100644 index 0000000..dd83616 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-20x20@3x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@1x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@1x.png new file mode 100644 index 0000000..9638a08 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@1x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png new file mode 100644 index 0000000..f7cff8f Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png new file mode 100644 index 0000000..ab9b5db Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@1x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@1x.png new file mode 100644 index 0000000..963e0d9 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@1x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png new file mode 100644 index 0000000..1940e0a Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png new file mode 100644 index 0000000..b173454 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-40x40@3x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png new file mode 100644 index 0000000..b173454 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-60x60@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png new file mode 100644 index 0000000..ef6d695 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-60x60@3x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-76x76@1x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-76x76@1x.png new file mode 100644 index 0000000..e237b95 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-76x76@1x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png new file mode 100644 index 0000000..8d48ca4 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-76x76@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png new file mode 100644 index 0000000..edc23c8 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/AppIcon.appiconset/Icon-83.5x83.5@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/Contents.json b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/CodeIslandActivityState+Payload.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/CodeIslandActivityState+Payload.swift new file mode 100644 index 0000000..432a824 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/CodeIslandActivityState+Payload.swift @@ -0,0 +1,51 @@ +import Foundation + +extension CodeIslandActivityAttributes.ContentState { + init(payload: CompanionStatePayload) { + self.init( + sequence: payload.sequence, + source: payload.source, + status: payload.status.rawValue, + toolName: payload.toolName, + workspaceName: payload.workspaceName, + message: payload.messages.last?.text, + pendingAction: payload.pendingAction?.rawValue, + questionText: payload.question?.question, + questionHeader: payload.question?.header, + questionProgress: payload.question.flatMap { question in + question.total > 1 ? "\(question.index)/\(question.total)" : nil + }, + sessions: Self.sessionPreviews(from: payload), + updatedAt: payload.updatedAt + ) + } + + private static func sessionPreviews(from payload: CompanionStatePayload) -> [CodeIslandSessionActivityPreview] { + let previews = payload.sessions + if !previews.isEmpty { + return previews.map { + CodeIslandSessionActivityPreview( + sessionId: $0.sessionId, + source: $0.source, + status: $0.status.rawValue, + toolName: $0.toolName, + workspaceName: $0.workspaceName, + message: $0.message, + updatedAt: $0.updatedAt + ) + } + } + + return [ + CodeIslandSessionActivityPreview( + sessionId: payload.sessionId, + source: payload.source, + status: payload.status.rawValue, + toolName: payload.toolName, + workspaceName: payload.workspaceName, + message: payload.question?.question ?? payload.messages.last?.text, + updatedAt: payload.updatedAt + ) + ] + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/CodeIslandCompanionApp.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/CodeIslandCompanionApp.swift new file mode 100644 index 0000000..2c8952d --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/CodeIslandCompanionApp.swift @@ -0,0 +1,54 @@ +import SwiftUI + +@main +struct CodeIslandCompanionApp: App { + @StateObject private var connection: CompanionConnection + @StateObject private var liveActivity: LiveActivityController + + init() { + let connection = CompanionConnection() + let liveActivity = LiveActivityController() + connection.onStateReceived = { [weak liveActivity] state in + Task { @MainActor in + liveActivity?.updateIfRunning(with: state) + } + } +#if DEBUG + Self.configureSmokeTestHooks(connection: connection, liveActivity: liveActivity) +#endif + _connection = StateObject(wrappedValue: connection) + _liveActivity = StateObject(wrappedValue: liveActivity) + } + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(connection) + .environmentObject(liveActivity) + } + } + +#if DEBUG + private static func configureSmokeTestHooks( + connection: CompanionConnection, + liveActivity: LiveActivityController + ) { + let arguments = ProcessInfo.processInfo.arguments + guard arguments.contains("-CodeIslandCompanionSmokeLiveActivity") else { return } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 700_000_000) + if let state = connection.latestState { + liveActivity.startOrUpdate(with: state) + } + + guard let flagIndex = arguments.firstIndex(of: "-CodeIslandCompanionSmokeDelayedState"), + arguments.indices.contains(flagIndex + 1) + else { return } + + try? await Task.sleep(nanoseconds: 4_000_000_000) + connection.injectMockState(named: arguments[flagIndex + 1]) + } + } +#endif +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionBluetoothCentral.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionBluetoothCentral.swift new file mode 100644 index 0000000..ec7110b --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionBluetoothCentral.swift @@ -0,0 +1,407 @@ +import Foundation +@preconcurrency import CoreBluetooth +import os + +@MainActor +final class CompanionBluetoothCentral: NSObject, ObservableObject { + private static let serviceUUID = CBUUID(string: "6D951BA3-8F41-4C45-9D8A-12085E0D7A10") + private static let notifyCharacteristicUUID = CBUUID(string: "25C1B67B-E903-4A0C-8A78-3EE8AB7317B7") + private static let restoreIdentifier = "top.fengye.CodeIslandCompanion.bluetooth-central" + private static let log = Logger(subsystem: "top.fengye.CodeIslandCompanion", category: "bluetooth-central") + + @Published private(set) var scanning = false + @Published private(set) var connectedPeripheralName: String? + @Published private(set) var lastError: String? + + var onSummary: ((CompanionBluetoothSummary) -> Void)? + + private var centralManager: CBCentralManager! + private var peripheral: CBPeripheral? + private var notifyCharacteristic: CBCharacteristic? + private var incoming: IncomingSequence? + private var lastDeliveredSequence: UInt64 = 0 + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + override init() { + super.init() + centralManager = CBCentralManager( + delegate: self, + queue: nil, + options: [ + CBCentralManagerOptionRestoreIdentifierKey: Self.restoreIdentifier, + CBCentralManagerOptionShowPowerAlertKey: true + ] + ) + } + + func start() { + guard centralManager.state == .poweredOn else { return } + startScanning() + } + + func stop() { + if centralManager.isScanning { + centralManager.stopScan() + } + scanning = false + + if let peripheral { + centralManager.cancelPeripheralConnection(peripheral) + } + + self.peripheral = nil + notifyCharacteristic = nil + connectedPeripheralName = nil + incoming = nil + } + + private func startScanning() { + guard centralManager.state == .poweredOn else { return } + guard !centralManager.isScanning else { + scanning = true + return + } + + centralManager.scanForPeripherals( + withServices: [Self.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + scanning = true + lastError = nil + } + + private func connect(_ discoveredPeripheral: CBPeripheral, advertisedName: String?) { + if peripheral?.identifier == discoveredPeripheral.identifier { + return + } + + peripheral = discoveredPeripheral + discoveredPeripheral.delegate = self + connectedPeripheralName = advertisedName ?? discoveredPeripheral.name ?? "CodeIsland Mac" + centralManager.connect( + discoveredPeripheral, + options: [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true + ] + ) + } + + private func handleChunk(_ data: Data) { + guard let chunk = CompanionBluetoothChunk(data: data) else { return } + guard chunk.sequence >= lastDeliveredSequence else { return } + + if incoming?.sequence != chunk.sequence || incoming?.total != chunk.total { + incoming = IncomingSequence(sequence: chunk.sequence, total: chunk.total) + } + + incoming?.chunks[chunk.index] = chunk.body + + guard let incoming, incoming.isComplete else { return } + let body = incoming.combined() + self.incoming = nil + + do { + let summary = try decoder.decode(CompanionBluetoothSummary.self, from: body) + lastDeliveredSequence = summary.sequence + lastError = nil + onSummary?(summary) + } catch { + lastError = error.localizedDescription + Self.log.error("failed to decode BLE summary: \(error.localizedDescription)") + } + } +} + +extension CompanionBluetoothCentral: CBCentralManagerDelegate { + nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { + Task { @MainActor in + switch central.state { + case .poweredOn: + self.lastError = nil + self.startScanning() + case .poweredOff: + self.scanning = false + self.connectedPeripheralName = nil + self.lastError = "蓝牙已关闭" + case .unauthorized: + self.scanning = false + self.connectedPeripheralName = nil + self.lastError = "蓝牙权限未授权" + case .unsupported: + self.scanning = false + self.connectedPeripheralName = nil + self.lastError = "这台 iPhone 不支持蓝牙" + case .resetting: + self.scanning = false + self.lastError = "蓝牙正在重置" + case .unknown: + self.scanning = false + @unknown default: + self.scanning = false + } + } + } + + nonisolated func centralManager( + _ central: CBCentralManager, + willRestoreState dict: [String: Any] + ) { + let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] + + Task { @MainActor in + if let restoredPeripheral = restored.first { + self.peripheral = restoredPeripheral + restoredPeripheral.delegate = self + self.connectedPeripheralName = restoredPeripheral.name ?? "CodeIsland Mac" + restoredPeripheral.discoverServices([Self.serviceUUID]) + } + + if central.state == .poweredOn { + self.startScanning() + } + } + } + + nonisolated func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String + + Task { @MainActor in + self.connect(peripheral, advertisedName: name) + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + Task { @MainActor in + if central.isScanning { + central.stopScan() + } + self.scanning = false + self.connectedPeripheralName = peripheral.name ?? self.connectedPeripheralName ?? "CodeIsland Mac" + peripheral.discoverServices([Self.serviceUUID]) + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + Task { @MainActor in + self.lastError = error?.localizedDescription ?? "无法连接 Mac 蓝牙摘要通道" + self.peripheral = nil + self.notifyCharacteristic = nil + self.connectedPeripheralName = nil + self.startScanning() + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + Task { @MainActor in + self.lastError = error?.localizedDescription + self.peripheral = nil + self.notifyCharacteristic = nil + self.connectedPeripheralName = nil + self.incoming = nil + self.startScanning() + } + } +} + +extension CompanionBluetoothCentral: CBPeripheralDelegate { + nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + Task { @MainActor in + if let error { + self.lastError = error.localizedDescription + return + } + + guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else { + self.lastError = "没有找到 CodeIsland 蓝牙服务" + return + } + + peripheral.discoverCharacteristics([Self.notifyCharacteristicUUID], for: service) + } + } + + nonisolated func peripheral( + _ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error? + ) { + Task { @MainActor in + if let error { + self.lastError = error.localizedDescription + return + } + + guard let characteristic = service.characteristics?.first(where: { $0.uuid == Self.notifyCharacteristicUUID }) else { + self.lastError = "没有找到 CodeIsland 蓝牙通知通道" + return + } + + self.notifyCharacteristic = characteristic + peripheral.setNotifyValue(true, for: characteristic) + } + } + + nonisolated func peripheral( + _ peripheral: CBPeripheral, + didUpdateNotificationStateFor characteristic: CBCharacteristic, + error: Error? + ) { + Task { @MainActor in + self.lastError = error?.localizedDescription + } + } + + nonisolated func peripheral( + _ peripheral: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error? + ) { + guard error == nil, let data = characteristic.value else { + let message = error?.localizedDescription + Task { @MainActor in self.lastError = message } + return + } + + Task { @MainActor in + self.handleChunk(data) + } + } +} + +struct CompanionBluetoothSummary: Codable { + struct SessionSummary: Codable { + let sessionId: String? + let source: String + let status: String + let toolName: String? + let workspaceName: String? + let message: String? + let updatedAt: Date + } + + let version: Int + let sequence: UInt64 + let sessionId: String? + let source: String + let status: String + let toolName: String? + let workspaceName: String? + let message: String? + let pendingAction: String? + let questionHeader: String? + let questionText: String? + let sessions: [SessionSummary]? + let updatedAt: Date + + var statePayload: CompanionStatePayload { + let status = CompanionStatus(rawValue: status) ?? .idle + let pendingAction = pendingAction.flatMap(CompanionPendingAction.init(rawValue:)) + let messages = message.map { + [CompanionMessagePreview(role: .assistant, text: $0)] + } ?? [] + let question = questionText.map { + CompanionQuestionPayload( + header: questionHeader, + question: $0, + options: [], + descriptions: [], + index: 1, + total: 1, + allowsMultipleSelection: false + ) + } + let sessionPreviews = (sessions ?? []).map { + CompanionSessionPreview( + sessionId: $0.sessionId, + source: $0.source, + status: CompanionStatus(rawValue: $0.status) ?? .idle, + toolName: $0.toolName, + workspaceName: $0.workspaceName, + message: $0.message, + updatedAt: $0.updatedAt + ) + } + + return CompanionStatePayload( + version: version, + sequence: sequence, + sessionId: sessionId, + source: source, + status: status, + toolName: toolName, + workspaceName: workspaceName, + messages: messages, + pendingAction: pendingAction, + question: question, + sessions: sessionPreviews, + updatedAt: updatedAt + ) + } +} + +private struct IncomingSequence { + let sequence: UInt64 + let total: Int + var chunks: [Int: Data] = [:] + + var isComplete: Bool { + chunks.count == total + } + + func combined() -> Data { + var data = Data() + for index in 0..= 15 else { return nil } + guard data[0] == 0x43, data[1] == 0x49, data[2] == 0x01 else { return nil } + + let sequence = data.readUInt64(at: 3) + let index = Int(data.readUInt16(at: 11)) + let total = Int(data.readUInt16(at: 13)) + guard total > 0, total <= 64, index >= 0, index < total else { return nil } + + self.sequence = sequence + self.index = index + self.total = total + self.body = data.subdata(in: 15.. UInt16 { + let value = (UInt16(self[offset]) << 8) | UInt16(self[offset + 1]) + return value + } + + func readUInt64(at offset: Int) -> UInt64 { + var value: UInt64 = 0 + for index in 0..<8 { + value = (value << 8) | UInt64(self[offset + index]) + } + return value + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionConnection.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionConnection.swift new file mode 100644 index 0000000..50cc96e --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionConnection.swift @@ -0,0 +1,412 @@ +import Combine +import Foundation +import MultipeerConnectivity +import UIKit + +@MainActor +final class CompanionConnection: NSObject, ObservableObject { + @Published private(set) var discoveredPeers: [MCPeerID] = [] + @Published private(set) var connectedPeer: MCPeerID? + @Published private(set) var latestState: CompanionStatePayload? { + didSet { + watchBridge.publish(latestState) + } + } + @Published private(set) var lastError: String? + @Published private(set) var browsing = false + @Published private(set) var bluetoothConnectedPeripheralName: String? + @Published private(set) var lastStateReceivedAt: Date? + @Published private(set) var isDemoMode = false + + private static let serviceType = "codeisland" + private static let refreshAfterSeconds: TimeInterval = 8 + private static let reconnectAfterSeconds: TimeInterval = 24 + + private let watchBridge = WatchBridge() + private let bluetoothBridge = CompanionBluetoothCentral() + private let peerID = MCPeerID(displayName: UIDevice.current.name) + private let mockStatePayload = CompanionConnection.mockStateFromLaunchArguments() + private lazy var session = MCSession(peer: peerID, securityIdentity: nil, encryptionPreference: .required) + private lazy var browser = MCNearbyServiceBrowser(peer: peerID, serviceType: Self.serviceType) + private var stateWatchdogTimer: Timer? + private var connectedAt: Date? + private var pendingReconnectPeer: MCPeerID? + private var demoSequence: UInt64 = 9000 + var onStateReceived: ((CompanionStatePayload) -> Void)? + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + override init() { + super.init() + session.delegate = self + browser.delegate = self + watchBridge.commandHandler = { [weak self] command in + self?.send(command) + } + bluetoothBridge.onSummary = { [weak self] summary in + self?.receiveBluetoothSummary(summary) + } + bluetoothBridge.$connectedPeripheralName + .assign(to: &$bluetoothConnectedPeripheralName) + bluetoothBridge.$lastError + .compactMap { $0 } + .assign(to: &$lastError) + + if let mockStatePayload { + connectedPeer = MCPeerID(displayName: "CodeIsland Mock Mac") + receiveState(mockStatePayload) + } + } + + deinit { + stateWatchdogTimer?.invalidate() + } + + func start() { + guard !isDemoMode else { return } + bluetoothBridge.start() + guard mockStatePayload == nil else { return } + startStateWatchdog() + guard !browsing else { return } + lastError = nil + browsing = true + browser.startBrowsingForPeers() + } + + func stop() { + if isDemoMode { + exitDemoMode() + return + } + guard mockStatePayload == nil else { return } + browsing = false + stateWatchdogTimer?.invalidate() + stateWatchdogTimer = nil + browser.stopBrowsingForPeers() + session.disconnect() + discoveredPeers = [] + connectedPeer = nil + connectedAt = nil + pendingReconnectPeer = nil + } + + func connect(to peer: MCPeerID) { + exitDemoMode() + browser.invitePeer(peer, to: session, withContext: nil, timeout: 12) + } + + func enterDemoMode() { + guard mockStatePayload == nil else { return } + browser.stopBrowsingForPeers() + session.disconnect() + browsing = false + discoveredPeers = [] + connectedAt = Date() + connectedPeer = MCPeerID(displayName: "Code Island Demo") + isDemoMode = true + receiveState(Self.mockState(named: "question", sequence: nextDemoSequence())) + } + + func cycleDemoState() { + guard isDemoMode else { return } + let states = ["question", "long", "interrupted", "idle"] + let nextIndex = Int((demoSequence - 9000) % UInt64(states.count)) + receiveState(Self.mockState(named: states[nextIndex], sequence: nextDemoSequence())) + } + + func exitDemoMode() { + guard isDemoMode else { return } + isDemoMode = false + latestState = nil + connectedPeer = nil + connectedAt = nil + lastStateReceivedAt = nil + lastError = nil + } + + func send(_ type: CompanionCommandType) { + send(type, answer: nil) + } + + func sendAnswer(_ answer: String) { + send(.answerQuestion, answer: answer) + } + + private func send(_ command: CompanionCommandPayload) { + guard !session.connectedPeers.isEmpty else { return } + + do { + let data = try encoder.encode(command) + try session.send(data, toPeers: session.connectedPeers, with: .reliable) + } catch { + lastError = error.localizedDescription + } + } + + private func send(_ type: CompanionCommandType, answer: String?) { + guard !session.connectedPeers.isEmpty else { return } + let command = CompanionCommandPayload( + type: type, + sessionId: latestState?.sessionId, + source: latestState?.source, + answer: answer + ) + send(command) + } + + private func requestCurrentState(reason: String) { + guard !session.connectedPeers.isEmpty else { return } + let command = CompanionCommandPayload(type: .requestCurrentState) + send(command) + } + + private func receiveState(_ state: CompanionStatePayload) { + lastStateReceivedAt = Date() + latestState = state + onStateReceived?(state) + } + + private func startStateWatchdog() { + guard stateWatchdogTimer == nil else { return } + stateWatchdogTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.checkStateFreshness() + } + } + } + + private func checkStateFreshness() { + guard mockStatePayload == nil else { return } + guard !isDemoMode else { return } + guard let connectedPeer else { return } + + let now = Date() + let reference = lastStateReceivedAt ?? connectedAt ?? now + let age = now.timeIntervalSince(reference) + + if age >= Self.reconnectAfterSeconds { + lastError = "连接在线但长时间没有状态更新,正在重新连接 Mac" + pendingReconnectPeer = connectedPeer + session.disconnect() + self.connectedPeer = nil + connectedAt = nil + if !browsing { + browsing = true + browser.startBrowsingForPeers() + } + if discoveredPeers.contains(connectedPeer) { + browser.invitePeer(connectedPeer, to: session, withContext: nil, timeout: 12) + } + return + } + + if age >= Self.refreshAfterSeconds { + requestCurrentState(reason: "stale-\(Int(age))s") + } + } + + func injectMockState(named name: String) { + receiveState(Self.mockState(named: name, sequence: nextDemoSequence())) + } + + private func nextDemoSequence() -> UInt64 { + demoSequence += 1 + return demoSequence + } + + private func receiveBluetoothSummary(_ summary: CompanionBluetoothSummary) { + guard !isDemoMode else { return } + + if let current = latestState, current.sequence > summary.sequence { + return + } + + receiveState(summary.statePayload) + } + + private static func mockStateFromLaunchArguments() -> CompanionStatePayload? { + let arguments = ProcessInfo.processInfo.arguments + guard let flagIndex = arguments.firstIndex(of: "-CodeIslandCompanionMockState"), + arguments.indices.contains(flagIndex + 1) + else { + return nil + } + + return mockState(named: arguments[flagIndex + 1]) + } + + private static func mockState(named name: String, sequence: UInt64? = nil) -> CompanionStatePayload { + let baseMessages = [ + CompanionMessagePreview(role: .user, text: "帮我生成一篇长篇小说"), + CompanionMessagePreview(role: .assistant, text: "好的,我先确认一下类型和篇幅,再开始组织结构。") + ] + let resolvedSequence = sequence ?? 1000 + + switch name.lowercased() { + case "question": + return CompanionStatePayload( + version: 1, + sequence: resolvedSequence, + sessionId: "mock-question", + source: "claude", + status: .waitingQuestion, + toolName: "AskUserQuestion", + workspaceName: "workspace", + messages: baseMessages, + pendingAction: .question, + question: CompanionQuestionPayload( + header: "小说类型", + question: "你想看什么类型的小说?", + options: ["都市 / 现实", "科幻", "悬疑 / 推理", "奇幻 / 玄幻"], + descriptions: [ + "现代都市、职场情感、现实生活", + "未来科技、AI、太空、时间旅行", + "犯罪侦查、谜团解谜、心理悬疑", + "魔法世界、修真、异世界冒险" + ], + index: 1, + total: 4, + allowsMultipleSelection: false + ), + updatedAt: Date() + ) + case "interrupted": + return CompanionStatePayload( + version: 1, + sequence: resolvedSequence, + sessionId: "mock-interrupted", + source: "claude", + status: .idle, + toolName: nil, + workspaceName: "workspace", + messages: [ + CompanionMessagePreview(role: .user, text: "帮我生成一篇长篇小说"), + CompanionMessagePreview(role: .assistant, text: "[Request interrupted by user]") + ], + pendingAction: nil, + question: nil, + updatedAt: Date() + ) + case "long": + return CompanionStatePayload( + version: 1, + sequence: resolvedSequence, + sessionId: "mock-long", + source: "codex", + status: .processing, + toolName: "WebSearch", + workspaceName: "workspace", + messages: [ + CompanionMessagePreview(role: .user, text: "把 iPhone 端所有容易截断的状态都自己跑一遍"), + CompanionMessagePreview(role: .assistant, text: "我会用模拟数据覆盖中断、提问、长文本和实时活动展示,重点检查中文化、滚动区域、最近动态字号以及按钮是否挤压。"), + CompanionMessagePreview(role: .assistant, text: "这是一条故意很长的最近动态,用来确认 iPhone 竖屏里不会被卡片裁掉,也不会因为内部嵌套滚动导致内容看不全。") + ], + pendingAction: nil, + question: nil, + updatedAt: Date() + ) + default: + return CompanionStatePayload( + version: 1, + sequence: resolvedSequence, + sessionId: "mock-idle", + source: "codex", + status: .idle, + toolName: nil, + workspaceName: "workspace", + messages: [], + pendingAction: nil, + question: nil, + updatedAt: Date() + ) + } + } +} + +extension CompanionConnection: MCNearbyServiceBrowserDelegate { + nonisolated func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) { + Task { @MainActor in + guard !self.discoveredPeers.contains(peerID) else { return } + self.discoveredPeers.append(peerID) + if self.pendingReconnectPeer == peerID { + self.pendingReconnectPeer = nil + self.connect(to: peerID) + } + } + } + + nonisolated func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + Task { @MainActor in + self.discoveredPeers.removeAll { $0 == peerID } + if self.connectedPeer == peerID { + self.connectedPeer = nil + } + } + } + + nonisolated func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error) { + Task { @MainActor in + self.browsing = false + self.lastError = error.localizedDescription + } + } +} + +extension CompanionConnection: MCSessionDelegate { + nonisolated func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + Task { @MainActor in + switch state { + case .connected: + self.connectedPeer = peerID + self.connectedAt = Date() + self.requestCurrentState(reason: "peer-connected") + case .notConnected: + if self.connectedPeer == peerID { + self.connectedPeer = nil + } + self.connectedAt = nil + case .connecting: + break + @unknown default: + break + } + } + } + + nonisolated func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + Task { @MainActor in + do { + self.receiveState(try self.decoder.decode(CompanionStatePayload.self, from: data)) + } catch { + self.lastError = error.localizedDescription + } + } + } + + nonisolated func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {} + + nonisolated func session( + _ session: MCSession, + didStartReceivingResourceWithName resourceName: String, + fromPeer peerID: MCPeerID, + with progress: Progress + ) {} + + nonisolated func session( + _ session: MCSession, + didFinishReceivingResourceWithName resourceName: String, + fromPeer peerID: MCPeerID, + at localURL: URL?, + withError error: Error? + ) {} +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionMascotView.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionMascotView.swift new file mode 100644 index 0000000..5eb867f --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionMascotView.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct CompanionMascotView: View { + let source: String + let status: CompanionStatus + var size: CGFloat = 27 + + var body: some View { + SharedMascotView(source: source, status: MascotAgentStatus(status.rawValue), size: size) + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionModels.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionModels.swift new file mode 100644 index 0000000..9184f6e --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/CompanionModels.swift @@ -0,0 +1,180 @@ +import Foundation + +enum CompanionStatus: String, Codable, Hashable { + case idle + case processing + case running + case waitingApproval + case waitingQuestion + + var label: String { + switch self { + case .idle: return "空闲" + case .processing: return "处理中" + case .running: return "运行中" + case .waitingApproval: return "等待批准" + case .waitingQuestion: return "等待回答" + } + } + + var shortLabel: String { + switch self { + case .idle: return "空闲" + case .processing: return "处理" + case .running: return "运行" + case .waitingApproval: return "批准" + case .waitingQuestion: return "问题" + } + } +} + +enum CompanionPendingAction: String, Codable { + case approval + case question +} + +enum CompanionMessageRole: String, Codable { + case user + case assistant + + var label: String { + switch self { + case .user: return "你" + case .assistant: return "助手" + } + } +} + +struct CompanionMessagePreview: Codable, Identifiable { + let id = UUID() + let role: CompanionMessageRole + let text: String + + private enum CodingKeys: String, CodingKey { + case role + case text + } +} + +struct CompanionQuestionPayload: Codable { + let header: String? + let question: String + let options: [String] + let descriptions: [String] + let index: Int + let total: Int + let allowsMultipleSelection: Bool +} + +struct CompanionSessionPreview: Codable, Identifiable, Hashable { + let sessionId: String? + let source: String + let status: CompanionStatus + let toolName: String? + let workspaceName: String? + let message: String? + let updatedAt: Date + + var id: String { + sessionId ?? "\(source)-\(workspaceName ?? "session")-\(updatedAt.timeIntervalSince1970)" + } +} + +struct CompanionStatePayload: Codable { + let version: Int + let sequence: UInt64 + let sessionId: String? + let source: String + let status: CompanionStatus + let toolName: String? + let workspaceName: String? + let messages: [CompanionMessagePreview] + let pendingAction: CompanionPendingAction? + let question: CompanionQuestionPayload? + let sessions: [CompanionSessionPreview] + let updatedAt: Date + + init( + version: Int, + sequence: UInt64, + sessionId: String?, + source: String, + status: CompanionStatus, + toolName: String?, + workspaceName: String?, + messages: [CompanionMessagePreview], + pendingAction: CompanionPendingAction?, + question: CompanionQuestionPayload?, + sessions: [CompanionSessionPreview] = [], + updatedAt: Date + ) { + self.version = version + self.sequence = sequence + self.sessionId = sessionId + self.source = source + self.status = status + self.toolName = toolName + self.workspaceName = workspaceName + self.messages = messages + self.pendingAction = pendingAction + self.question = question + self.sessions = sessions + self.updatedAt = updatedAt + } + + private enum CodingKeys: String, CodingKey { + case version + case sequence + case sessionId + case source + case status + case toolName + case workspaceName + case messages + case pendingAction + case question + case sessions + case updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decode(Int.self, forKey: .version) + sequence = try container.decode(UInt64.self, forKey: .sequence) + sessionId = try container.decodeIfPresent(String.self, forKey: .sessionId) + source = try container.decode(String.self, forKey: .source) + status = try container.decode(CompanionStatus.self, forKey: .status) + toolName = try container.decodeIfPresent(String.self, forKey: .toolName) + workspaceName = try container.decodeIfPresent(String.self, forKey: .workspaceName) + messages = try container.decode([CompanionMessagePreview].self, forKey: .messages) + pendingAction = try container.decodeIfPresent(CompanionPendingAction.self, forKey: .pendingAction) + question = try container.decodeIfPresent(CompanionQuestionPayload.self, forKey: .question) + sessions = try container.decodeIfPresent([CompanionSessionPreview].self, forKey: .sessions) ?? [] + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } +} + +enum CompanionCommandType: String, Codable { + case requestCurrentState + case approveCurrentPermission + case denyCurrentPermission + case skipCurrentQuestion + case answerQuestion + case focus +} + +struct CompanionCommandPayload: Codable { + let version: Int + let type: CompanionCommandType + let sessionId: String? + let source: String? + let answer: String? + + init(version: Int = 1, type: CompanionCommandType, sessionId: String? = nil, source: String? = nil, answer: String? = nil) { + self.version = version + self.type = type + self.sessionId = sessionId + self.source = source + self.answer = answer + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift new file mode 100644 index 0000000..cc0728f --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift @@ -0,0 +1,1151 @@ +import MultipeerConnectivity +import SwiftUI + +private enum CodeIslandMotion { + static let open = Animation.spring(response: 0.42, dampingFraction: 0.82) + static let close = Animation.spring(response: 0.38, dampingFraction: 1.0) + static let pop = Animation.spring(response: 0.3, dampingFraction: 0.65) + static let micro = Animation.easeOut(duration: 0.12) +} + +struct ContentView: View { + @EnvironmentObject private var connection: CompanionConnection + @EnvironmentObject private var liveActivity: LiveActivityController + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + Color(red: 0.015, green: 0.016, blue: 0.018) + .ignoresSafeArea() + + if proxy.size.width > proxy.size.height, let state = connection.latestState { + StandByIsland(state: state, availableSize: proxy.size) + .environmentObject(connection) + .environmentObject(liveActivity) + } else { + PortraitIslandView(topPadding: max(86, proxy.safeAreaInsets.top + 8)) + .environmentObject(connection) + .environmentObject(liveActivity) + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .top) + } + } + .onAppear { + connection.start() + } + .onChange(of: connection.latestState?.sequence) { _, _ in + guard liveActivity.isRunning, let state = connection.latestState else { return } + liveActivity.startOrUpdate(with: state) + } + .animation(CodeIslandMotion.open, value: connection.connectedPeer) + .animation(CodeIslandMotion.pop, value: connection.latestState?.status) + .animation(CodeIslandMotion.micro, value: connection.browsing) + } + .ignoresSafeArea(.container, edges: .vertical) + .preferredColorScheme(.dark) + .accessibilityIdentifier("companion.root") + } +} + +private struct PortraitIslandView: View { + let topPadding: CGFloat + @EnvironmentObject private var connection: CompanionConnection + @EnvironmentObject private var liveActivity: LiveActivityController + + var body: some View { + GeometryReader { proxy in + ScrollView(.vertical) { + LazyVStack(spacing: 10) { + CompactIslandBar() + .environmentObject(connection) + + if let state = connection.latestState { + LiveIslandCard(state: state) + .environmentObject(connection) + .environmentObject(liveActivity) + .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) + + MessageStrip(messages: state.messages) + } else { + DiscoveryIsland() + .environmentObject(connection) + .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) + + DiscoveryFill() + } + + if let error = connection.lastError { + DiagnosticStrip(message: error) + .transition(.blurFade.combined(with: .move(edge: .top))) + } + + if let error = liveActivity.lastError { + LiveActivityDiagnosticStrip(message: error) + .environmentObject(liveActivity) + .transition(.blurFade.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 12) + .padding(.top, topPadding) + .padding(.bottom, max(28, proxy.safeAreaInsets.bottom + 20)) + .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) + } + .scrollIndicators(.automatic) + .scrollBounceBehavior(.basedOnSize) + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .top) + .accessibilityIdentifier("companion.scroll") + } + } +} + +private struct PrimaryMessageView: View { + let state: CompanionStatePayload + + var body: some View { + let text = state.question?.question + ?? CompanionDisplayText.message(state.messages.last?.text) + ?? "当前没有新的消息" + + MorphText( + text: text, + font: .system(size: 16, weight: .medium), + color: .white.opacity(state.messages.isEmpty && state.question == nil ? 0.55 : 0.86), + lineLimit: state.question == nil ? 5 : 3 + ) + .fixedSize(horizontal: false, vertical: true) + } +} + +private struct MetadataChipRow: View { + let workspaceName: String? + let toolName: String? + + private var workspaceText: String? { + CompanionDisplayText.workspace(workspaceName) + } + + private var toolText: String? { + CompanionDisplayText.tool(toolName) + } + + var body: some View { + if workspaceText != nil || toolText != nil { + HStack(spacing: 8) { + if let workspaceText { + TinyChip(icon: "folder", text: workspaceText) + } + if let toolText { + TinyChip(icon: "hammer", text: toolText) + } + } + .accessibilityElement(children: .combine) + } + } +} + +private struct QuestionOptionsView: View { + let question: CompanionQuestionPayload + @EnvironmentObject private var connection: CompanionConnection + + var body: some View { + if question.allowsMultipleSelection { + Text("多选问题请先在 Mac 上回答") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.52)) + .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) + } else if question.options.isEmpty { + Text("文本回答请先在 Mac 上输入") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.52)) + .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) + } else { + LazyVStack(spacing: 7) { + ForEach(Array(question.options.enumerated()), id: \.offset) { index, option in + Button { + connection.sendAnswer(option) + } label: { + HStack(alignment: .top, spacing: 10) { + Text("\(index + 1).") + .font(.system(size: 12, weight: .black, design: .monospaced)) + .foregroundStyle(Color(red: 0.38, green: 0.68, blue: 1.0)) + .frame(width: 24, alignment: .leading) + + VStack(alignment: .leading, spacing: 2) { + Text(option) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white.opacity(0.86)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + if question.descriptions.indices.contains(index) { + Text(question.descriptions[index]) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.45)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).stroke(Color.white.opacity(0.07))) + } + .buttonStyle(.plain) + } + } + } + } +} + +private struct DiscoveryFill: View { + @EnvironmentObject private var connection: CompanionConnection + + var body: some View { + VStack(spacing: 12) { + DividerLine() + .padding(.top, 2) + + Text("保持 iPhone 与 Mac 在同一网络,CodeIsland 会持续同步当前状态。") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.42)) + .multilineTextAlignment(.center) + .padding(.horizontal, 18) + + IslandButton( + title: "进入演示模式", + icon: "play.rectangle.fill", + tint: Color(red: 0.25, green: 0.76, blue: 1.0), + accessibilityIdentifier: "companion.enterDemoMode" + ) { + connection.enterDemoMode() + } + .padding(.horizontal, 14) + } + .frame(maxWidth: .infinity, alignment: .top) + } +} + +private struct CompactIslandBar: View { + @EnvironmentObject private var connection: CompanionConnection + + var body: some View { + HStack(spacing: 8) { + CompanionMascotView(source: connection.latestState?.source ?? "codex", status: compactStatus, size: 30) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + MorphText( + text: connection.latestState?.source.uppercased() ?? "CODEISLAND", + font: .system(size: 12, weight: .black, design: .rounded), + color: .white + ) + MorphText( + text: compactSubtitle, + font: .system(size: 10, weight: .medium, design: .monospaced), + color: .white.opacity(0.52) + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + + Spacer() + + ConnectionDot(active: connection.connectedPeer != nil, browsing: connection.browsing) + + Button { + connection.browsing ? connection.stop() : connection.start() + } label: { + Image(systemName: connection.browsing ? "stop.circle.fill" : "dot.radiowaves.left.and.right") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white.opacity(0.86)) + .frame(width: 38, height: 38) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(connection.browsing ? "停止搜索 Mac" : "搜索 Mac") + .accessibilityIdentifier("companion.search.toggle") + } + .padding(.leading, 8) + .padding(.trailing, 6) + .frame(height: 46) + .background(IslandShellShape().fill(.black)) + .overlay(IslandShellShape().stroke(Color.white.opacity(0.08), lineWidth: 1)) + .shadow(color: .black.opacity(0.38), radius: 16, y: 8) + } + + private var compactStatus: CompanionStatus { + connection.latestState?.status ?? (connection.browsing ? .processing : .idle) + } + + private var compactSubtitle: String { + if let state = connection.latestState { + if let toolName = state.toolName, !toolName.isEmpty { + return CompanionDisplayText.tool(toolName) ?? toolName + } + if let workspaceName = state.workspaceName, !workspaceName.isEmpty { + return CompanionDisplayText.workspace(workspaceName) ?? workspaceName + } + return state.status.label + } + if let peer = connection.connectedPeer { + return peer.displayName + } + return connection.browsing ? "搜索中" : "离线" + } +} + +private struct LiveIslandCard: View { + let state: CompanionStatePayload + @EnvironmentObject private var connection: CompanionConnection + @EnvironmentObject private var liveActivity: LiveActivityController + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + MorphText( + text: state.source.isEmpty ? "CodeIsland" : state.source.uppercased(), + font: .system(size: 15, weight: .bold, design: .rounded), + color: .white + ) + MorphText( + text: CompanionDisplayText.subtitle( + workspaceName: state.workspaceName, + toolName: state.toolName, + fallback: "Mac 已连接" + ), + font: .system(size: 12, weight: .medium), + color: .white.opacity(0.58) + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 10) + + if state.pendingAction != nil { + StatusPill(status: state.status) + } else { + HeaderStatusDot(status: state.status) + } + } + .frame(minHeight: 52) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 10) + + DividerLine() + + VStack(alignment: .leading, spacing: state.question == nil ? 14 : 10) { + PrimaryMessageView(state: state) + + MetadataChipRow(workspaceName: state.workspaceName, toolName: state.toolName) + + if let question = state.question { + QuestionPromptCard(question: question) + .environmentObject(connection) + .transition(.blurFade.combined(with: .move(edge: .top))) + } + + CommandRow(state: state) + .environmentObject(connection) + .environmentObject(liveActivity) + } + .padding(14) + .transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top))) + } + .background(IslandShellShape().fill(.black)) + .overlay(IslandShellShape().stroke(Color.white.opacity(0.08), lineWidth: 1)) + .shadow(color: .black.opacity(0.35), radius: 18, y: 10) + .accessibilityElement(children: .contain) + .accessibilityLabel("CodeIsland 状态") + .accessibilityIdentifier("companion.statusCard") + } +} + +private struct QuestionPromptCard: View { + let question: CompanionQuestionPayload + @EnvironmentObject private var connection: CompanionConnection + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("?") + .font(.system(size: 13, weight: .black, design: .monospaced)) + .foregroundStyle(Color(red: 0.38, green: 0.68, blue: 1.0)) + if let header = question.header, !header.isEmpty { + Text(header) + .font(.caption2.weight(.black)) + .foregroundStyle(Color(red: 0.38, green: 0.68, blue: 1.0)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color(red: 0.38, green: 0.68, blue: 1.0).opacity(0.14), in: Capsule()) + } + Spacer() + if question.total > 1 { + Text("\(question.index)/\(question.total)") + .font(.caption2.weight(.black)) + .foregroundStyle(.white.opacity(0.48)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.white.opacity(0.08), in: Capsule()) + } + } + + Text(question.question) + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white.opacity(0.9)) + .lineLimit(5) + + QuestionOptionsView(question: question) + .environmentObject(connection) + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color(red: 0.04, green: 0.05, blue: 0.06))) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).stroke(Color.orange.opacity(0.24))) + .accessibilityIdentifier("companion.questionCard") + } +} + +private struct DiscoveryIsland: View { + @EnvironmentObject private var connection: CompanionConnection + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + MorphText( + text: connection.connectedPeer == nil ? "等待 Mac" : "已连接 Mac", + font: .system(size: 15, weight: .bold, design: .rounded), + color: .white + ) + MorphText( + text: subtitle, + font: .system(size: 12, weight: .medium), + color: .white.opacity(0.58) + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + ConnectionDot(active: connection.connectedPeer != nil, browsing: connection.browsing) + } + .frame(minHeight: 52) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 10) + + DividerLine() + + VStack(spacing: 10) { + if connection.discoveredPeers.isEmpty { + HStack(spacing: 10) { + ProgressView() + .tint(.green) + Text(connection.browsing ? "正在搜索附近的 CodeIsland" : "搜索已停止") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.white.opacity(0.72)) + Spacer() + } + .frame(minHeight: 48) + } else { + ForEach(connection.discoveredPeers, id: \.self) { peer in + Button { + connection.connect(to: peer) + } label: { + HStack(spacing: 10) { + Image(systemName: "macbook") + .font(.headline) + .foregroundStyle(.green) + .frame(width: 32, height: 32) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Text(peer.displayName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + + Spacer() + + Image(systemName: "arrow.right") + .foregroundStyle(.white.opacity(0.5)) + } + .frame(minHeight: 48) + } + .buttonStyle(.plain) + } + } + } + .padding(14) + } + .background(IslandShellShape().fill(.black)) + .overlay(IslandShellShape().stroke(Color.white.opacity(0.08), lineWidth: 1)) + .shadow(color: .black.opacity(0.35), radius: 18, y: 10) + .accessibilityIdentifier("companion.discoveryCard") + } + + private var subtitle: String { + if let peer = connection.connectedPeer { + return peer.displayName + } + if connection.discoveredPeers.isEmpty { + return connection.browsing ? "广播握手中" : "点右上角继续搜索" + } + return "发现 \(connection.discoveredPeers.count) 台设备" + } +} + +private struct CommandRow: View { + let state: CompanionStatePayload + @EnvironmentObject private var connection: CompanionConnection + @EnvironmentObject private var liveActivity: LiveActivityController + + var body: some View { + VStack(spacing: 8) { + if connection.isDemoMode { + HStack(spacing: 8) { + IslandButton( + title: "切换演示状态", + icon: "arrow.triangle.2.circlepath", + tint: Color(red: 0.25, green: 0.76, blue: 1.0), + accessibilityIdentifier: "companion.demo.nextState" + ) { + connection.cycleDemoState() + } + IslandButton( + title: "退出演示", + icon: "xmark", + tint: .red, + accessibilityIdentifier: "companion.demo.exit" + ) { + connection.exitDemoMode() + } + } + } + + if state.pendingAction == .question { + HStack(spacing: 8) { + IslandButton( + title: "在 Mac 回答", + icon: "arrow.up.forward.app.fill", + tint: Color(red: 0.35, green: 0.85, blue: 0.45), + accessibilityIdentifier: "companion.command.focus" + ) { + connection.send(.focus) + } + IslandButton( + title: "跳过", + icon: "forward.fill", + tint: .orange, + accessibilityIdentifier: "companion.command.skip" + ) { + connection.send(.skipCurrentQuestion) + } + } + .transition(.blurFade.combined(with: .move(edge: .top))) + + LiveActivityInlineButton(state: state) + } else { + HStack(spacing: 8) { + IslandButton( + title: "打开 Mac 会话", + icon: "arrow.up.forward.app.fill", + tint: Color(red: 0.35, green: 0.85, blue: 0.45), + accessibilityIdentifier: "companion.command.focus" + ) { + connection.send(.focus) + } + + IslandButton( + title: liveActivity.isRunning ? "更新实时活动" : "开启实时活动", + icon: liveActivity.isRunning ? "arrow.clockwise" : "bolt.horizontal.fill", + tint: Color(red: 0.25, green: 0.76, blue: 1.0), + accessibilityIdentifier: "companion.liveActivity.primaryButton" + ) { + liveActivity.startOrUpdate(with: state) + } + } + + if state.pendingAction == .approval { + HStack(spacing: 8) { + IslandButton(title: "批准", icon: "checkmark", tint: .orange, accessibilityIdentifier: "companion.command.approve") { + connection.send(.approveCurrentPermission) + } + IslandButton(title: "拒绝", icon: "xmark", tint: .red, accessibilityIdentifier: "companion.command.deny") { + connection.send(.denyCurrentPermission) + } + } + .transition(.blurFade.combined(with: .move(edge: .top))) + } + + if liveActivity.isRunning { + LiveActivityInlineButton(state: state) + } + } + } + } +} + +private struct LiveActivityInlineButton: View { + let state: CompanionStatePayload + @EnvironmentObject private var liveActivity: LiveActivityController + + var body: some View { + Button { + if liveActivity.isRunning { + liveActivity.stop() + } else { + liveActivity.startOrUpdate(with: state) + } + } label: { + Label( + liveActivity.isRunning ? "停止实时活动" : "同步到实时活动", + systemImage: liveActivity.isRunning ? "stop.circle.fill" : "bolt.horizontal.fill" + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(liveActivity.isRunning ? .white.opacity(0.62) : Color(red: 0.25, green: 0.76, blue: 1.0).opacity(0.86)) + .frame(maxWidth: .infinity, minHeight: 34) + } + .buttonStyle(.plain) + .accessibilityIdentifier("companion.liveActivity.inlineButton") + } +} + +private struct MessageStrip: View { + let messages: [CompanionMessagePreview] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("最近动态") + .font(.system(size: 13, weight: .bold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.45)) + .textCase(.uppercase) + Rectangle() + .fill(.white.opacity(0.10)) + .frame(height: 0.5) + } + + if messages.isEmpty { + HStack(spacing: 8) { + PulseDot(status: .idle) + Text("等待下一条同步消息") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white.opacity(0.5)) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) + } else { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(Array(messages.suffix(3))) { message in + HStack(alignment: .top, spacing: 12) { + Text(message.role.label) + .font(.system(size: 13, weight: .black)) + .foregroundStyle(message.role == .user ? .black : .white) + .frame(width: 42, height: 28) + .background(message.role == .user ? Color.white.opacity(0.86) : Color.white.opacity(0.12), in: Capsule()) + + Text(CompanionDisplayText.message(message.text) ?? message.text) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white.opacity(0.76)) + .lineLimit(6) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .transition(.blurFade.combined(with: .move(edge: .top))) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.white.opacity(0.045))) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).stroke(Color.white.opacity(0.06))) + .accessibilityIdentifier("companion.messages") + } +} + +private struct StandByIsland: View { + let state: CompanionStatePayload + let availableSize: CGSize + @EnvironmentObject private var connection: CompanionConnection + @EnvironmentObject private var liveActivity: LiveActivityController + + private var sessions: [CompanionSessionPreview] { + standbySessions(for: state) + } + + private var activeCount: Int { + sessions.filter { $0.status != .idle }.count + } + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 16) { + CompanionMascotView(source: state.source, status: state.status, size: 78) + + VStack(alignment: .leading, spacing: 5) { + MorphText( + text: sessions.count > 1 ? "CODE ISLAND" : (state.source.isEmpty ? "CODEISLAND" : state.source.uppercased()), + font: .system(size: 32, weight: .black, design: .rounded), + color: .white + ) + MorphText( + text: sessions.count > 1 ? "\(sessions.count) 个会话 · \(activeCount) 个活跃" : state.status.label, + font: .system(size: 22, weight: .semibold, design: .rounded), + color: activeCount > 0 ? .green : statusColor(state.status) + ) + } + } + + MorphText( + text: CompanionDisplayText.message(state.messages.last?.text) + ?? CompanionDisplayText.workspace(state.workspaceName) + ?? "CodeIsland 已连接", + font: .system(size: 24, weight: .medium, design: .rounded), + color: .white.opacity(0.82), + lineLimit: 4 + ) + .minimumScaleFactor(0.72) + + HStack(spacing: 10) { + if let workspaceText = CompanionDisplayText.workspace(state.workspaceName) { + TinyChip(icon: "folder", text: workspaceText) + } + if let toolText = CompanionDisplayText.tool(state.toolName) { + TinyChip(icon: "hammer", text: toolText) + } + } + } + .frame(maxWidth: sessions.count > 1 ? availableSize.width * 0.42 : .infinity, alignment: .leading) + .padding(24) + + DividerLine(vertical: true) + + if sessions.count > 1 { + StandBySessionBoard(sessions: sessions, activeCount: activeCount) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(20) + } else { + VStack(spacing: 10) { + IconIslandButton(icon: "arrow.up.forward.app.fill", tint: Color(red: 0.35, green: 0.85, blue: 0.45)) { + connection.send(.focus) + } + IconIslandButton(icon: liveActivity.isRunning ? "arrow.clockwise" : "bolt.horizontal.fill", tint: Color(red: 0.25, green: 0.76, blue: 1.0)) { + liveActivity.startOrUpdate(with: state) + } + if state.pendingAction != nil { + IconIslandButton(icon: "checkmark", tint: .orange) { + connection.send(.approveCurrentPermission) + } + IconIslandButton(icon: "xmark", tint: .red) { + connection.send(.denyCurrentPermission) + } + } + } + .padding(18) + } + } + .frame( + width: min(760, max(0, availableSize.width - 28)), + height: max(260, availableSize.height - 24) + ) + .background(IslandShellShape().fill(.black)) + .overlay(IslandShellShape().stroke(Color.white.opacity(0.08), lineWidth: 1)) + .shadow(color: .black.opacity(0.45), radius: 24, y: 14) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } +} + +private struct StandBySessionBoard: View { + let sessions: [CompanionSessionPreview] + let activeCount: Int + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Text("会话") + .font(.system(size: 18, weight: .black, design: .rounded)) + .foregroundStyle(.white) + StandByCountBadge(count: sessions.count, activeCount: activeCount) + Spacer(minLength: 0) + } + + VStack(spacing: 8) { + ForEach(Array(sessions.prefix(4))) { session in + StandBySessionRow(session: session) + } + } + + if sessions.count > 4 { + Text("还有 \(sessions.count - 4) 个会话") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.48)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 2) + } + } + } +} + +private struct StandBySessionRow: View { + let session: CompanionSessionPreview + + var body: some View { + HStack(spacing: 10) { + CompanionMascotView(source: session.source, status: session.status, size: 38) + .frame(width: 42, height: 42) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(session.source.isEmpty ? "CODEISLAND" : session.source.uppercased()) + .font(.system(size: 15, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + if let workspace = CompanionDisplayText.workspace(session.workspaceName) { + Text(workspace) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.48)) + .lineLimit(1) + } + } + Text(standbySessionText(session)) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.66)) + .lineLimit(1) + } + + Spacer(minLength: 8) + + PulseDot(status: session.status) + .frame(width: 24, height: 24) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.white.opacity(0.07), lineWidth: 1)) + } +} + +private struct StandByCountBadge: View { + let count: Int + let activeCount: Int + + var body: some View { + Text(activeCount > 0 ? "\(activeCount) 活跃" : "\(count) 总计") + .font(.system(size: 12, weight: .black, design: .rounded)) + .foregroundStyle(activeCount > 0 ? .green : .white.opacity(0.64)) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background((activeCount > 0 ? Color.green : Color.white).opacity(0.12), in: Capsule()) + } +} + +private func standbySessions(for state: CompanionStatePayload) -> [CompanionSessionPreview] { + guard !state.sessions.isEmpty else { + return [ + CompanionSessionPreview( + sessionId: state.sessionId, + source: state.source, + status: state.status, + toolName: state.toolName, + workspaceName: state.workspaceName, + message: state.question?.question ?? state.messages.last?.text, + updatedAt: state.updatedAt + ) + ] + } + return state.sessions +} + +private func standbySessionText(_ session: CompanionSessionPreview) -> String { + if let message = CompanionDisplayText.message(session.message), !message.isEmpty { + return message + } + if let toolName = CompanionDisplayText.tool(session.toolName), !toolName.isEmpty { + return toolName + } + return session.status.label +} + +private struct MorphText: View { + let text: String + var font: Font = .system(size: 12) + var color: Color = .white + var lineLimit: Int? = 1 + + @State private var displayed: String + @State private var blur: CGFloat = 0 + @State private var generation = 0 + + init(text: String, font: Font = .system(size: 12), color: Color = .white, lineLimit: Int? = 1) { + self.text = text + self.font = font + self.color = color + self.lineLimit = lineLimit + _displayed = State(initialValue: text) + } + + var body: some View { + Text(displayed) + .font(font) + .foregroundStyle(color) + .lineLimit(lineLimit) + .blur(radius: blur * 4) + .opacity(1 - blur * 0.15) + .animation(CodeIslandMotion.micro, value: blur) + .onChange(of: text) { _, newText in + guard newText != displayed else { return } + generation += 1 + let current = generation + withAnimation(.easeOut(duration: 0.1)) { blur = 1 } + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(60)) + guard current == generation else { return } + displayed = newText + withAnimation(.easeOut(duration: 0.15)) { blur = 0 } + } + } + } +} + +private struct IslandShellShape: Shape { + func path(in rect: CGRect) -> Path { + RoundedRectangle(cornerRadius: 18, style: .continuous).path(in: rect) + } +} + +private struct DividerLine: View { + var vertical = false + + var body: some View { + Rectangle() + .fill(Color.white.opacity(0.12)) + .frame(width: vertical ? 0.5 : nil, height: vertical ? nil : 0.5) + } +} + +private struct StatusPill: View { + let status: CompanionStatus + + var body: some View { + HStack(spacing: 6) { + PulseDot(status: status) + Text(status.shortLabel) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(Color.white.opacity(0.08), in: Capsule()) + } +} + +private struct HeaderStatusDot: View { + let status: CompanionStatus + + var body: some View { + PulseDot(status: status) + .frame(width: 30, height: 30) + .background(Color.white.opacity(0.07), in: Capsule()) + .accessibilityLabel(status.label) + } +} + +private struct PulseDot: View { + let status: CompanionStatus + + var body: some View { + TimelineView(.animation) { timeline in + let scale = pulseScale(timeline.date.timeIntervalSinceReferenceDate) + Circle() + .fill(statusColor(status)) + .frame(width: 8, height: 8) + .overlay { + Circle() + .stroke(statusColor(status).opacity(0.5), lineWidth: 1) + .scaleEffect(scale) + .opacity(max(0, 1.2 - scale)) + } + } + .frame(width: 14, height: 14) + } + + private func pulseScale(_ phase: TimeInterval) -> CGFloat { + switch status { + case .idle: + return 1 + case .processing, .running: + return 1 + CGFloat((sin(phase * 4.2) + 1) * 0.28) + case .waitingApproval, .waitingQuestion: + return 1 + CGFloat((sin(phase * 7.0) + 1) * 0.42) + } + } +} + +private struct ConnectionDot: View { + let active: Bool + let browsing: Bool + + var body: some View { + PulseDot(status: active ? .running : (browsing ? .processing : .idle)) + .frame(width: 30, height: 30) + .background(Color.white.opacity(0.08), in: Capsule()) + .accessibilityLabel(active ? "Mac 已连接" : (browsing ? "正在搜索 Mac" : "Mac 未连接")) + } +} + +private struct TinyChip: View { + let icon: String + let text: String + + var body: some View { + Label { + Text(text) + .lineLimit(1) + } icon: { + Image(systemName: icon) + } + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.64)) + .padding(.horizontal, 9) + .padding(.vertical, 7) + .background(Color.white.opacity(0.07), in: Capsule()) + } +} + +private struct IslandButton: View { + let title: String + let icon: String + let tint: Color + var accessibilityIdentifier: String? = nil + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(title, systemImage: icon) + .font(.system(size: 13, weight: .bold)) + .lineLimit(1) + .minimumScaleFactor(0.82) + .foregroundStyle(tint == .orange ? .black : .white) + .frame(maxWidth: .infinity, minHeight: 44) + .background(buttonBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).stroke(tint.opacity(0.42))) + } + .buttonStyle(.plain) + .optionalAccessibilityIdentifier(accessibilityIdentifier) + } + + private var buttonBackground: Color { + tint == .orange ? .orange : tint.opacity(0.20) + } +} + +private extension View { + @ViewBuilder + func optionalAccessibilityIdentifier(_ identifier: String?) -> some View { + if let identifier { + accessibilityIdentifier(identifier) + } else { + self + } + } +} + +private struct IconIslandButton: View { + let icon: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.title3.weight(.bold)) + .foregroundStyle(tint == .orange ? .black : .white) + .frame(width: 52, height: 52) + .background(tint == .orange ? .orange : tint.opacity(0.22), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).stroke(tint.opacity(0.45))) + } + .buttonStyle(.plain) + } +} + +private struct DiagnosticStrip: View { + let message: String + + var body: some View { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.footnote.weight(.medium)) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.orange.opacity(0.12))) + } +} + +private struct LiveActivityDiagnosticStrip: View { + let message: String + @EnvironmentObject private var liveActivity: LiveActivityController + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label(message, systemImage: "bolt.horizontal.circle.fill") + .font(.footnote.weight(.medium)) + .foregroundStyle(Color(red: 0.35, green: 0.75, blue: 1.0)) + + Button { + liveActivity.stopAll() + } label: { + Label("清理已有实时活动后重试", systemImage: "trash") + .font(.caption.weight(.bold)) + .foregroundStyle(.white.opacity(0.82)) + .frame(maxWidth: .infinity, minHeight: 34) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color(red: 0.10, green: 0.18, blue: 0.24))) + } +} + +private struct BlurFadeModifier: ViewModifier { + let active: Bool + + func body(content: Content) -> some View { + content + .blur(radius: active ? 5 : 0) + .opacity(active ? 0 : 1) + } +} + +private extension AnyTransition { + static var blurFade: AnyTransition { + .modifier( + active: BlurFadeModifier(active: true), + identity: BlurFadeModifier(active: false) + ) + } +} + +private func statusColor(_ status: CompanionStatus) -> Color { + switch status { + case .idle: + return Color(red: 0.55, green: 0.60, blue: 0.68) + case .processing, .running: + return Color(red: 0.30, green: 0.85, blue: 0.40) + case .waitingApproval, .waitingQuestion: + return Color(red: 1.0, green: 0.55, blue: 0.0) + } +} + +#Preview { + ContentView() + .environmentObject(CompanionConnection()) + .environmentObject(LiveActivityController()) +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/Info.plist b/ios/CodeIslandCompanion/CodeIslandCompanion/Info.plist new file mode 100644 index 0000000..f9e1230 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDisplayName + Code Island + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSBonjourServices + + _codeisland._tcp + + NSLocalNetworkUsageDescription + CodeIsland 会在本地网络中发现你的 Mac,用于同步当前会话状态。 + NSBluetoothAlwaysUsageDescription + Code Island 使用蓝牙在后台接收 Mac 的轻量状态摘要,用于刷新实时活动和同步手表状态。 + NSSupportsLiveActivities + + UIBackgroundModes + + bluetooth-central + + UILaunchStoryboardName + LaunchScreen + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/LaunchScreen.storyboard b/ios/CodeIslandCompanion/CodeIslandCompanion/LaunchScreen.storyboard new file mode 100644 index 0000000..fbbdfba --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/LaunchScreen.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/LiveActivityController.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/LiveActivityController.swift new file mode 100644 index 0000000..9979b56 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/LiveActivityController.swift @@ -0,0 +1,200 @@ +import ActivityKit +import Foundation + +@MainActor +final class LiveActivityController: ObservableObject { + private static let layoutVersionKey = "CodeIslandLiveActivityLayoutVersion" + private static let currentLayoutVersion = "2026-05-29-compact-multi-session-v3" + + @Published private(set) var activityID: String? + @Published private(set) var lastError: String? + @Published private(set) var existingActivityCount = 0 + + private var activity: Activity? + private var lastContentState: CodeIslandActivityAttributes.ContentState? + private var activityStateTask: Task? + + var isRunning: Bool { + activity != nil + } + + deinit { + activityStateTask?.cancel() + } + + init() { + Task { + await migrateLiveActivityLayoutIfNeeded() + recoverExistingActivity() + } + } + + func updateIfRunning(with payload: CompanionStatePayload) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + Task { + let shouldRecreate = await migrateLiveActivityLayoutIfNeeded() + await recoverExistingActivity(endingDuplicates: true) + guard activity != nil || shouldRecreate else { return } + await apply(payload, createIfNeeded: shouldRecreate) + } + } + + func startOrUpdate(with payload: CompanionStatePayload) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + lastError = "这台 iPhone 没有开启实时活动。" + return + } + + Task { + await migrateLiveActivityLayoutIfNeeded() + await recoverExistingActivity(endingDuplicates: true) + await apply(payload, createIfNeeded: true) + } + } + + func stop() { + stopAll() + } + + func stopAll() { + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + clearActivity(id: activityID) + existingActivityCount = 0 + lastError = nil + } + } + + private func recoverExistingActivity() { + existingActivityCount = Activity.activities.count + guard activity == nil, let existing = newestExistingActivity() else { return } + activity = existing + activityID = existing.id + lastContentState = existing.content.state + observeState(of: existing) + } + + private func recoverExistingActivity(endingDuplicates: Bool) async { + existingActivityCount = Activity.activities.count + guard let existing = newestExistingActivity() else { + if activity != nil { + clearActivity(id: activityID) + } + return + } + + if activityID != existing.id { + activity = existing + activityID = existing.id + lastContentState = existing.content.state + observeState(of: existing) + } + + guard endingDuplicates else { return } + for duplicate in Activity.activities where duplicate.id != existing.id { + await duplicate.end(nil, dismissalPolicy: .immediate) + } + existingActivityCount = Activity.activities.count + } + + private func newestExistingActivity() -> Activity? { + Activity.activities.max { + $0.content.state.updatedAt < $1.content.state.updatedAt + } + } + + @discardableResult + private func migrateLiveActivityLayoutIfNeeded() async -> Bool { + let storedVersion = UserDefaults.standard.string(forKey: Self.layoutVersionKey) + guard storedVersion != Self.currentLayoutVersion else { return false } + + let existingActivities = Activity.activities + for activity in existingActivities { + await activity.end(nil, dismissalPolicy: .immediate) + } + + if !existingActivities.isEmpty { + clearActivity(id: activityID) + } + existingActivityCount = Activity.activities.count + UserDefaults.standard.set(Self.currentLayoutVersion, forKey: Self.layoutVersionKey) + return !existingActivities.isEmpty + } + + private func apply(_ payload: CompanionStatePayload, createIfNeeded: Bool) async { + do { + let contentState = CodeIslandActivityAttributes.ContentState(payload: payload) + lastContentState = contentState + + if let activity { + await update(activity, with: contentState, status: payload.status) + lastError = nil + return + } + + guard createIfNeeded else { return } + let attributes = CodeIslandActivityAttributes(sessionId: payload.sessionId) + let content = ActivityContent( + state: contentState, + staleDate: Date().addingTimeInterval(90), + relevanceScore: relevanceScore(for: payload.status) + ) + let existing = try Activity.request(attributes: attributes, content: content) + activity = existing + activityID = existing.id + observeState(of: existing) + lastError = nil + existingActivityCount = Activity.activities.count + } catch { + lastError = error.localizedDescription + recoverExistingActivity() + } + } + + private func update( + _ activity: Activity, + with contentState: CodeIslandActivityAttributes.ContentState, + status: CompanionStatus + ) async { + await activity.update(ActivityContent( + state: contentState, + staleDate: Date().addingTimeInterval(90), + relevanceScore: relevanceScore(for: status) + )) + } + + private func observeState(of activity: Activity) { + activityStateTask?.cancel() + activityStateTask = Task { [weak self] in + for await state in activity.activityStateUpdates { + guard state == .ended || state == .dismissed else { continue } + self?.clearActivity(id: activity.id) + break + } + } + } + + private func clearActivity(id: String?) { + guard activityID == nil || activityID == id else { return } + activity = nil + activityID = nil + lastContentState = nil + existingActivityCount = Activity.activities.count + activityStateTask?.cancel() + activityStateTask = nil + } + + private func relevanceScore(for status: CompanionStatus) -> Double { + switch status { + case .waitingApproval, .waitingQuestion: + return 1 + case .processing, .running: + return 0.7 + case .idle: + return 0.25 + } + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion/WatchBridge.swift b/ios/CodeIslandCompanion/CodeIslandCompanion/WatchBridge.swift new file mode 100644 index 0000000..91e9f17 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanion/WatchBridge.swift @@ -0,0 +1,113 @@ +import Foundation +import WatchConnectivity +import os + +@MainActor +final class WatchBridge: NSObject { + var commandHandler: ((CompanionCommandPayload) -> Void)? + + private static let log = Logger(subsystem: "top.fengye.CodeIslandCompanion", category: "watch-bridge") + + private var latestState: CompanionStatePayload? + private var activationState: WCSessionActivationState = .notActivated + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + override init() { + super.init() + + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + func publish(_ state: CompanionStatePayload?) { + latestState = state + flushLatestState(reason: "publish") + } + + private func flushLatestState(reason: String) { + guard let state = latestState, WCSession.isSupported() else { return } + guard activationState == .activated else { + Self.log.debug("deferred watch sync before activation: \(reason)") + return + } + + do { + let data = try encoder.encode(state) + let message: [String: Any] = ["state": data] + try WCSession.default.updateApplicationContext(message) + + if WCSession.default.isReachable { + WCSession.default.sendMessage(message, replyHandler: nil) + } + } catch { + Self.log.error("watch sync failed: \(error.localizedDescription)") + } + } +} + +extension WatchBridge: WCSessionDelegate { + nonisolated func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + Task { @MainActor in + self.activationState = activationState + if let error { + Self.log.error("watch session activation failed: \(error.localizedDescription)") + } + self.flushLatestState(reason: "activation") + } + } + + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} + + nonisolated func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + Task { @MainActor in + self.flushLatestState(reason: "reachability") + } + } + + nonisolated func sessionWatchStateDidChange(_ session: WCSession) { + Task { @MainActor in + self.flushLatestState(reason: "watch-state") + } + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + receive(message) + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + receive(applicationContext) + } + + private nonisolated func receive(_ message: [String: Any]) { + guard let data = message["command"] as? Data else { return } + + Task { @MainActor in + do { + let command = try decoder.decode(CompanionCommandPayload.self, from: data) + commandHandler?(command) + } catch { + // Ignore malformed watch commands. + } + } + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanionUITests/CodeIslandCompanionUITests.swift b/ios/CodeIslandCompanion/CodeIslandCompanionUITests/CodeIslandCompanionUITests.swift new file mode 100644 index 0000000..e0ae6ca --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanionUITests/CodeIslandCompanionUITests.swift @@ -0,0 +1,49 @@ +import XCTest + +final class CodeIslandCompanionUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testQuestionStateRendersPrimaryControls() throws { + let app = launchApp(mockState: "question") + + XCTAssertTrue(app.otherElements["companion.statusCard"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.otherElements["companion.questionCard"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.buttons["companion.command.focus"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["companion.command.skip"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["companion.liveActivity.inlineButton"].waitForExistence(timeout: 5)) + } + + @MainActor + func testLongMessageStateCanScrollToRecentActivity() throws { + let app = launchApp(mockState: "long") + + XCTAssertTrue(app.otherElements["companion.statusCard"].waitForExistence(timeout: 8)) + + let messages = app.otherElements["companion.messages"] + if !messages.waitForExistence(timeout: 4) { + app.scrollViews["companion.scroll"].swipeUp() + } + XCTAssertTrue(messages.waitForExistence(timeout: 4)) + XCTAssertTrue(app.buttons["companion.liveActivity.primaryButton"].waitForExistence(timeout: 5)) + } + + @MainActor + func testIdleStateKeepsMacAndLiveActivityActionsReachable() throws { + let app = launchApp(mockState: "idle") + + XCTAssertTrue(app.otherElements["companion.statusCard"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.buttons["companion.command.focus"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["companion.liveActivity.primaryButton"].waitForExistence(timeout: 5)) + } + + @MainActor + private func launchApp(mockState: String) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-CodeIslandCompanionMockState", mockState] + app.launch() + return app + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandCompanionWidgetBundle.swift b/ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandCompanionWidgetBundle.swift new file mode 100644 index 0000000..974ecc2 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandCompanionWidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct CodeIslandCompanionWidgetBundle: WidgetBundle { + var body: some Widget { + CodeIslandLiveActivityWidget() + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift b/ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift new file mode 100644 index 0000000..1b9cbed --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift @@ -0,0 +1,605 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct CodeIslandLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: CodeIslandActivityAttributes.self) { context in + LockScreenActivityView(state: context.state) + .activityBackgroundTint(Color(red: 0.04, green: 0.05, blue: 0.07)) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + AgentBadge(state: context.state) + } + DynamicIslandExpandedRegion(.trailing) { + ExpandedTrailingStatus(state: context.state) + } + DynamicIslandExpandedRegion(.bottom) { + ExpandedMessageView(state: context.state) + } + } compactLeading: { + CompactAgentView(state: context.state) + } compactTrailing: { + CompactStatusView(state: context.state) + } minimal: { + MinimalMascotBadge(state: context.state) + } + .keylineTint(statusColor(context.state.status)) + } + } +} + +private struct MinimalMascotBadge: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + ZStack { + Circle() + .fill(Color(red: 0.09, green: 0.10, blue: 0.12)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.06), lineWidth: 0.5) + ) + + SharedMascotView( + source: state.source, + status: MascotAgentStatus(state.status), + size: 18 + ) + } + .frame(width: 34, height: 34) + .clipShape(Circle()) + .contentShape(Circle()) + } +} + +private struct LockScreenActivityView: View { + let state: CodeIslandActivityAttributes.ContentState + + private var sessions: [CodeIslandSessionActivityPreview] { + displaySessions(state) + } + + var body: some View { + if sessions.count > 1 { + MultiSessionLockScreenActivityView(state: state, sessions: sessions) + } else { + SingleSessionLockScreenActivityView(state: state) + } + } +} + +private struct SingleSessionLockScreenActivityView: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + AgentBadge(state: state) + Spacer() + StatusPill(state: state) + } + + MetadataRow(state: state) + + if !primaryText(state).isEmpty { + Text(primaryText(state)) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(3) + } else if let toolName = CompanionDisplayText.tool(state.toolName), !toolName.isEmpty { + Label(toolName, systemImage: "hammer") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(2) + } else { + Text("当前没有新的消息") + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + } + .padding(16) + } +} + +private struct MultiSessionLockScreenActivityView: View { + let state: CodeIslandActivityAttributes.ContentState + let sessions: [CodeIslandSessionActivityPreview] + + private var visibleSessions: ArraySlice { + sessions.prefix(2) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + MultiSessionOverviewBadge(state: state) + + VStack(alignment: .leading, spacing: 2) { + Text("CODE ISLAND") + .font(.system(size: 12, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + Text(sessionSummary) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.58)) + .lineLimit(1) + } + + Spacer(minLength: 8) + CompactSessionCountPill(count: sessions.count, activeCount: state.activeSessionCount) + } + + VStack(spacing: 5) { + ForEach(visibleSessions) { session in + MultiSessionLockScreenRow(session: session) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private var sessionSummary: String { + if state.activeSessionCount > 0 { + return "\(sessions.count) 个会话 · \(state.activeSessionCount) 个活跃" + } + return "\(sessions.count) 个会话同步中" + } +} + +private struct MultiSessionOverviewBadge: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + SharedMascotView( + source: state.source, + status: MascotAgentStatus(state.status), + size: 24 + ) + .frame(width: 28, height: 28) + } +} + +private struct CompactSessionCountPill: View { + let count: Int + let activeCount: Int + + var body: some View { + HStack(spacing: 5) { + StatusDot(status: activeCount > 0 ? "running" : "idle", size: 7) + Text(activeCount > 0 ? "\(activeCount) 活跃" : "\(count) 会话") + .font(.system(size: 11, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.white.opacity(0.10), in: Capsule()) + } +} + +private struct MultiSessionLockScreenRow: View { + let session: CodeIslandSessionActivityPreview + + var body: some View { + HStack(spacing: 8) { + SharedMascotView( + source: session.source, + status: MascotAgentStatus(session.status), + size: 22 + ) + .frame(width: 26, height: 26) + + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + Text(session.sourceLabel) + .font(.system(size: 11, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + + if let workspace = CompanionDisplayText.workspace(session.workspaceName) { + Text(workspace) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.54)) + .lineLimit(1) + } + } + + Text(sessionText(session)) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.66)) + .lineLimit(1) + } + + Spacer(minLength: 6) + + Text(session.statusLabel) + .font(.system(size: 10, weight: .black, design: .rounded)) + .foregroundStyle(statusColor(session.status)) + .lineLimit(1) + .minimumScaleFactor(0.72) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(statusColor(session.status).opacity(0.18), in: Capsule()) + } + .frame(height: 38) + .padding(.horizontal, 9) + .background(Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + } +} + +private struct ExpandedMessageView: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + if displaySessions(state).count > 1 { + ExpandedSessionOverview(state: state) + } else { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(state.compactStatusLabel) + .font(.caption2.weight(.black)) + .foregroundStyle(statusColor(state.status)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(statusColor(state.status).opacity(0.18), in: Capsule()) + Text(CompanionDisplayText.workspace(state.workspaceName) ?? "CodeIsland") + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .lineLimit(1) + if let toolName = CompanionDisplayText.tool(state.toolName), !toolName.isEmpty { + Text(toolName) + .font(.caption2.weight(.semibold)) + .foregroundStyle(toolColor(toolName)) + .lineLimit(1) + } + if let progress = state.questionProgress { + Text(progress) + .font(.caption2.weight(.black)) + .foregroundStyle(.white.opacity(0.62)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.white.opacity(0.10), in: Capsule()) + } + } + Text(primaryText(state)) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.78)) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private struct ExpandedSessionOverview: View { + let state: CodeIslandActivityAttributes.ContentState + + private var sessions: [CodeIslandSessionActivityPreview] { + displaySessions(state) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text("\(sessions.count) 个会话") + .font(.caption2.weight(.black)) + .foregroundStyle(.white) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.white.opacity(0.12), in: Capsule()) + if state.activeSessionCount > 0 { + Text("\(state.activeSessionCount) 活跃") + .font(.caption2.weight(.black)) + .foregroundStyle(.green) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.green.opacity(0.14), in: Capsule()) + } + if let progress = state.questionProgress { + Text(progress) + .font(.caption2.weight(.black)) + .foregroundStyle(.white.opacity(0.62)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.white.opacity(0.10), in: Capsule()) + } + Spacer(minLength: 0) + } + + SessionStackView(sessions: sessions, compact: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct CompactAgentView: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 4) { + SharedMascotView(source: state.source, status: MascotAgentStatus(state.status), size: 20) + Text(displaySessions(state).count > 1 ? "\(displaySessions(state).count)" : state.sourceLabel) + .font(.system(size: 10, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + } +} + +private struct ExpandedStatusDot: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + StatusDot(status: state.status, size: 8) + .padding(8) + .background(statusColor(state.status).opacity(0.22), in: Circle()) + .accessibilityLabel(state.statusLabel) + } +} + +private struct ExpandedTrailingStatus: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + if displaySessions(state).count > 1 { + SessionCountPill(count: displaySessions(state).count, activeCount: state.activeSessionCount) + } else { + ExpandedStatusDot(state: state) + } + } +} + +private struct CompactStatusView: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 3) { + StatusDot(status: state.status, size: 6) + Text(displaySessions(state).count > 1 ? "会话" : state.compactStatusLabel) + .font(.system(size: 9, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.74) + } + } +} + +private struct AgentBadge: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 8) { + SharedMascotView(source: state.source, status: MascotAgentStatus(state.status), size: 34) + + VStack(alignment: .leading, spacing: 1) { + Text(state.sourceLabel) + .font(.system(size: 13, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + Text(CompanionDisplayText.subtitle( + workspaceName: state.workspaceName, + toolName: state.toolName, + fallback: "CodeIsland" + )) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.62)) + .lineLimit(1) + } + } + } +} + +private struct SessionCountPill: View { + let count: Int + let activeCount: Int + + var body: some View { + HStack(spacing: 6) { + StatusDot(status: activeCount > 0 ? "running" : "idle", size: 8) + Text(activeCount > 0 ? "\(activeCount) 个活跃" : "\(count) 个会话") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.10), in: Capsule()) + } +} + +private struct StatusPill: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 6) { + StatusDot(status: state.status, size: 8) + Text(state.statusLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(statusColor(state.status).opacity(0.2), in: Capsule()) + } +} + +private struct SessionStackView: View { + let sessions: [CodeIslandSessionActivityPreview] + var compact: Bool + + var body: some View { + VStack(spacing: compact ? 4 : 6) { + ForEach(sessions.prefix(compact ? 2 : 3)) { session in + SessionPreviewRow(session: session, compact: compact) + } + } + } +} + +private struct SessionPreviewRow: View { + let session: CodeIslandSessionActivityPreview + var compact: Bool + + var body: some View { + HStack(spacing: compact ? 6 : 8) { + SharedMascotView( + source: session.source, + status: MascotAgentStatus(session.status), + size: compact ? 18 : 22 + ) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + Text(session.sourceLabel) + .font(.system(size: compact ? 10 : 11, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + if let workspace = CompanionDisplayText.workspace(session.workspaceName) { + Text(workspace) + .font(.system(size: compact ? 9 : 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + } + Text(sessionText(session)) + .font(.system(size: compact ? 9 : 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.62)) + .lineLimit(1) + } + Spacer(minLength: 0) + Text(session.statusLabel) + .font(.system(size: compact ? 9 : 10, weight: .black, design: .rounded)) + .foregroundStyle(statusColor(session.status)) + .padding(.horizontal, compact ? 6 : 7) + .padding(.vertical, compact ? 3 : 4) + .background(statusColor(session.status).opacity(0.16), in: Capsule()) + } + .padding(.horizontal, compact ? 0 : 8) + .padding(.vertical, compact ? 0 : 6) + .background(compact ? Color.clear : Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private struct StatusDot: View { + let status: String + var size: CGFloat = 8 + + var body: some View { + Circle() + .fill(statusColor(status)) + .frame(width: size, height: size) + } +} + +private struct MetadataRow: View { + let state: CodeIslandActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 8) { + if let workspaceName = CompanionDisplayText.workspace(state.workspaceName), !workspaceName.isEmpty { + CompactChip(icon: "folder", text: workspaceName) + } + if let toolName = CompanionDisplayText.tool(state.toolName), !toolName.isEmpty { + CompactChip(icon: "hammer", text: toolName, tint: toolColor(toolName)) + } + Spacer(minLength: 0) + } + } +} + +private struct CompactChip: View { + let icon: String + let text: String + var tint: Color = .white.opacity(0.64) + + var body: some View { + Label { + Text(text) + .lineLimit(1) + } icon: { + Image(systemName: icon) + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.white.opacity(0.08), in: Capsule()) + } +} + +private func compactStatusText(_ state: CodeIslandActivityAttributes.ContentState) -> String { + switch state.status { + case "waitingApproval": return "批" + case "waitingQuestion": return "问" + case "processing": return "跑" + case "running": return state.toolName?.prefix(1).uppercased() ?? "跑" + default: return "" + } +} + +private func primaryText(_ state: CodeIslandActivityAttributes.ContentState) -> String { + if state.status == "waitingQuestion", let questionText = CompanionDisplayText.message(state.questionText), !questionText.isEmpty { + return questionText + } + if let message = CompanionDisplayText.message(state.message), !message.isEmpty { + return message + } + if let toolName = CompanionDisplayText.tool(state.toolName), !toolName.isEmpty { + return toolName + } + return state.statusLabel +} + +private func displaySessions(_ state: CodeIslandActivityAttributes.ContentState) -> [CodeIslandSessionActivityPreview] { + guard !state.sessions.isEmpty else { + return [ + CodeIslandSessionActivityPreview( + sessionId: nil, + source: state.source, + status: state.status, + toolName: state.toolName, + workspaceName: state.workspaceName, + message: primaryText(state), + updatedAt: state.updatedAt + ) + ] + } + return state.sessions +} + +private func sessionText(_ session: CodeIslandSessionActivityPreview) -> String { + if let message = CompanionDisplayText.message(session.message), !message.isEmpty { + return message + } + if let toolName = CompanionDisplayText.tool(session.toolName), !toolName.isEmpty { + return toolName + } + return session.statusLabel +} + +private func toolColor(_ tool: String) -> Color { + switch tool.lowercased() { + case "bash": return Color(red: 0.4, green: 1.0, blue: 0.5) + case "edit", "write": return Color(red: 0.5, green: 0.7, blue: 1.0) + case "read": return Color(red: 0.9, green: 0.8, blue: 0.4) + case "grep", "glob": return Color(red: 0.8, green: 0.6, blue: 1.0) + case "agent": return Color(red: 1.0, green: 0.6, blue: 0.4) + default: return .white.opacity(0.7) + } +} + +private func statusColor(_ status: String) -> Color { + switch status { + case "waitingApproval", "waitingQuestion": + return Color(red: 1.0, green: 0.74, blue: 0.25) + case "processing", "running": + return Color(red: 0.30, green: 0.72, blue: 1.0) + default: + return Color(red: 0.55, green: 0.60, blue: 0.68) + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandCompanionWidget/Info.plist b/ios/CodeIslandCompanion/CodeIslandCompanionWidget/Info.plist new file mode 100644 index 0000000..12cdb51 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandCompanionWidget/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + CodeIsland + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..318f64f --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,140 @@ +{ + "images" : [ + { + "filename" : "Icon-24x24@2x.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "Icon-27.5x27.5@2x.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "Icon-29x29@2x.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-29x29@3x.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "Icon-33x33@2x.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "Icon-40x40@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "Icon-44x44@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "Icon-46x46@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "Icon-50x50@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "filename" : "Icon-51x51@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "filename" : "Icon-54x54@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "Icon-86x86@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "Icon-98x98@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "Icon-108x108@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "filename" : "Icon-117x117@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "filename" : "Icon-129x129@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "Icon-1024x1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024.png new file mode 100644 index 0000000..108cdf2 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-1024x1024.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-108x108@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-108x108@2x.png new file mode 100644 index 0000000..1248e11 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-108x108@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-117x117@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-117x117@2x.png new file mode 100644 index 0000000..78bcb19 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-117x117@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-129x129@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-129x129@2x.png new file mode 100644 index 0000000..4ae02c4 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-129x129@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-24x24@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-24x24@2x.png new file mode 100644 index 0000000..26b0e7d Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-24x24@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-27.5x27.5@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-27.5x27.5@2x.png new file mode 100644 index 0000000..f73e0a3 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-27.5x27.5@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png new file mode 100644 index 0000000..f7cff8f Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-29x29@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png new file mode 100644 index 0000000..ab9b5db Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-29x29@3x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-33x33@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-33x33@2x.png new file mode 100644 index 0000000..6abaab4 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-33x33@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png new file mode 100644 index 0000000..1940e0a Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-40x40@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-44x44@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-44x44@2x.png new file mode 100644 index 0000000..12abf93 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-44x44@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-46x46@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-46x46@2x.png new file mode 100644 index 0000000..4d81c41 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-46x46@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-50x50@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-50x50@2x.png new file mode 100644 index 0000000..a3d0469 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-50x50@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-51x51@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-51x51@2x.png new file mode 100644 index 0000000..89b22bf Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-51x51@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-54x54@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-54x54@2x.png new file mode 100644 index 0000000..0bc7a62 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-54x54@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-86x86@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-86x86@2x.png new file mode 100644 index 0000000..6289ef7 Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-86x86@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-98x98@2x.png b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-98x98@2x.png new file mode 100644 index 0000000..e86888b Binary files /dev/null and b/ios/CodeIslandCompanion/CodeIslandWatchApp/Assets.xcassets/AppIcon.appiconset/Icon-98x98@2x.png differ diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/CodeIslandWatchApp.entitlements b/ios/CodeIslandCompanion/CodeIslandWatchApp/CodeIslandWatchApp.entitlements new file mode 100644 index 0000000..2e95cd8 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchApp/CodeIslandWatchApp.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.top.fengye.CodeIslandCompanion + + + diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/CodeIslandWatchApp.swift b/ios/CodeIslandCompanion/CodeIslandWatchApp/CodeIslandWatchApp.swift new file mode 100644 index 0000000..d60b663 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchApp/CodeIslandWatchApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct CodeIslandWatchApp: App { + @StateObject private var connection = WatchConnection() + + var body: some Scene { + WindowGroup { + WatchContentView() + .environmentObject(connection) + } + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/Info.plist b/ios/CodeIslandCompanion/CodeIslandWatchApp/Info.plist new file mode 100644 index 0000000..731bd52 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchApp/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDisplayName + Code Island + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + WKApplication + + WKCompanionAppBundleIdentifier + top.fengye.CodeIslandCompanion + + diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/WatchConnection.swift b/ios/CodeIslandCompanion/CodeIslandWatchApp/WatchConnection.swift new file mode 100644 index 0000000..37457c8 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchApp/WatchConnection.swift @@ -0,0 +1,253 @@ +import Foundation +import UserNotifications +import WatchKit +import WatchConnectivity +import WidgetKit + +@MainActor +final class WatchConnection: NSObject, ObservableObject { + @Published private(set) var latestState: CompanionStatePayload? + @Published private(set) var lastError: String? + @Published private(set) var activationState: WCSessionActivationState = .notActivated + private var lastHapticSequence: UInt64? + private var lastNotificationSequence: UInt64? + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + override init() { + super.init() + + guard WCSession.isSupported() else { + lastError = "这台设备不支持与 iPhone 同步" + return + } + +#if DEBUG && targetEnvironment(simulator) + // The smoke-test simulator does not need local notification permission. +#elseif DEBUG + let isSmokeTest = ProcessInfo.processInfo.arguments.contains("-CodeIslandWatchSmokeState") + if !isSmokeTest { + requestNotificationAuthorization() + } +#else + requestNotificationAuthorization() +#endif + WCSession.default.delegate = self + WCSession.default.activate() + +#if DEBUG + if let state = Self.mockStateFromLaunchArguments() { + receiveState(state) + } else if let data = WCSession.default.receivedApplicationContext["state"] as? Data { + decodeState(data) + } +#else + if let data = WCSession.default.receivedApplicationContext["state"] as? Data { + decodeState(data) + } +#endif + } + + func send(_ type: CompanionCommandType, answer: String? = nil) { + WKInterfaceDevice.current().play(.click) + + guard WCSession.default.isReachable else { + lastError = "iPhone 暂不可达" + WKInterfaceDevice.current().play(.failure) + return + } + + let command = CompanionCommandPayload( + type: type, + sessionId: latestState?.sessionId, + source: latestState?.source, + answer: answer + ) + + do { + let data = try encoder.encode(command) + WCSession.default.sendMessage(["command": data], replyHandler: nil) { [weak self] error in + Task { @MainActor in + self?.lastError = error.localizedDescription + WKInterfaceDevice.current().play(.failure) + } + } + } catch { + lastError = error.localizedDescription + WKInterfaceDevice.current().play(.failure) + } + } + + private func decodeState(_ data: Data) { + do { + let nextState = try decoder.decode(CompanionStatePayload.self, from: data) + receiveState(nextState) + } catch { + lastError = error.localizedDescription + WKInterfaceDevice.current().play(.failure) + } + } + + private func receiveState(_ nextState: CompanionStatePayload) { + let previousState = latestState + latestState = nextState + lastError = nil + WatchStateStore.save(nextState) + WidgetCenter.shared.reloadAllTimelines() + playHapticIfNeeded(previous: previousState, next: nextState) + scheduleNotificationIfNeeded(previous: previousState, next: nextState) + } + + private func requestNotificationAuthorization() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + private func playHapticIfNeeded(previous: CompanionStatePayload?, next: CompanionStatePayload) { + guard lastHapticSequence != next.sequence else { return } + lastHapticSequence = next.sequence + + guard let previous else { return } + + if next.pendingAction == .approval || next.pendingAction == .question { + WKInterfaceDevice.current().play(.notification) + } else if previous.status != next.status || previous.messages.count != next.messages.count { + WKInterfaceDevice.current().play(.click) + } + } + + private func scheduleNotificationIfNeeded(previous: CompanionStatePayload?, next: CompanionStatePayload) { + guard previous != nil else { return } + guard lastNotificationSequence != next.sequence else { return } + guard next.pendingAction == .approval || next.pendingAction == .question else { return } + + lastNotificationSequence = next.sequence + + let content = UNMutableNotificationContent() + content.title = "\(CompanionDisplayText.source(next.source)) 需要处理" + content.body = next.question?.question + ?? CompanionDisplayText.message(next.messages.last?.text) + ?? next.status.label + content.sound = .default + + let request = UNNotificationRequest( + identifier: "code-island-\(next.sequence)", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) + } + +#if DEBUG + private static func mockStateFromLaunchArguments() -> CompanionStatePayload? { + let arguments = ProcessInfo.processInfo.arguments + guard let flagIndex = arguments.firstIndex(of: "-CodeIslandWatchSmokeState"), + arguments.indices.contains(flagIndex + 1) + else { + return nil + } + + return mockState(named: arguments[flagIndex + 1]) + } + + private static func mockState(named name: String) -> CompanionStatePayload { + switch name.lowercased() { + case "question": + return CompanionStatePayload( + version: 1, + sequence: 9202, + sessionId: "watch-question", + source: "claude", + status: .waitingQuestion, + toolName: "AskUserQuestion", + workspaceName: "fengye", + messages: [ + CompanionMessagePreview(role: .user, text: "帮我写一篇长篇小说"), + CompanionMessagePreview(role: .assistant, text: "我需要先确认小说类型和基调。") + ], + pendingAction: .question, + question: CompanionQuestionPayload( + header: "小说类型", + question: "你想写什么类型的小说?", + options: ["科幻", "悬疑推理", "都市现实", "奇幻冒险"], + descriptions: [], + index: 0, + total: 3, + allowsMultipleSelection: false + ), + updatedAt: Date() + ) + case "long": + return CompanionStatePayload( + version: 1, + sequence: 9203, + sessionId: "watch-long", + source: "codex", + status: .processing, + toolName: "WebSearch", + workspaceName: "workspace", + messages: [ + CompanionMessagePreview(role: .user, text: "重点测试退到后台之后灵动岛和手表还能不能收到新消息"), + CompanionMessagePreview(role: .assistant, text: "我会先用模拟器验证 UI 和本地同步路径,再把真机 BLE 后台唤醒列成单独验收项。"), + CompanionMessagePreview(role: .assistant, text: "这是一条较长的 watch 动态内容,用来确认滚动页面不会被底部按钮或系统区域裁掉。") + ], + pendingAction: nil, + question: nil, + updatedAt: Date() + ) + default: + return CompanionStatePayload( + version: 1, + sequence: 9201, + sessionId: "watch-idle", + source: "codex", + status: .idle, + toolName: nil, + workspaceName: "workspace", + messages: [], + pendingAction: nil, + question: nil, + updatedAt: Date() + ) + } + } +#endif +} + +extension WatchConnection: WCSessionDelegate { + nonisolated func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + Task { @MainActor in + self.activationState = activationState + self.lastError = error?.localizedDescription + } + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + receive(message) + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + receive(applicationContext) + } + + private nonisolated func receive(_ message: [String: Any]) { + guard let data = message["state"] as? Data else { return } + + Task { @MainActor in + decodeState(data) + } + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandWatchApp/WatchContentView.swift b/ios/CodeIslandCompanion/CodeIslandWatchApp/WatchContentView.swift new file mode 100644 index 0000000..13d0c4c --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchApp/WatchContentView.swift @@ -0,0 +1,567 @@ +import SwiftUI +import WatchKit + +struct WatchContentView: View { + @EnvironmentObject private var connection: WatchConnection + @State private var selectedPage = WatchPage.initial + + var body: some View { + Group { + if let state = connection.latestState { + TabView(selection: $selectedPage) { + WatchStatusPage(state: state) + .tag(WatchPage.status) + WatchMessagePage(state: state) + .tag(WatchPage.message) + WatchActionsPage(state: state) + .tag(WatchPage.actions) + WatchActivityPage(messages: state.messages) + .tag(WatchPage.activity) + } + .tabViewStyle(.verticalPage) + .onChange(of: selectedPage) { _, _ in + WKInterfaceDevice.current().play(.click) + } + } else { + WatchEmptyView(error: connection.lastError) + } + } + .background(Color.black.ignoresSafeArea()) + } +} + +private enum WatchPage: Hashable { + case status + case message + case actions + case activity + + static var initial: WatchPage { +#if DEBUG + let arguments = ProcessInfo.processInfo.arguments + guard let flagIndex = arguments.firstIndex(of: "-CodeIslandWatchSmokePage"), + arguments.indices.contains(flagIndex + 1) + else { + return .status + } + + switch arguments[flagIndex + 1].lowercased() { + case "message": + return .message + case "actions": + return .actions + case "activity": + return .activity + default: + return .status + } +#else + return .status +#endif + } +} + +private struct WatchStatusPage: View { + let state: CompanionStatePayload + + var body: some View { + GeometryReader { proxy in + let isCompact = proxy.size.height < 430 + let mascotSize: CGFloat = isCompact ? 64 : 90 + VStack(spacing: isCompact ? 5 : 10) { + Spacer(minLength: 0) + + SharedMascotView( + source: state.source, + status: MascotAgentStatus(state.status.rawValue), + size: mascotSize + ) + .frame(height: mascotSize + 4) + + VStack(spacing: 2) { + Text(CompanionDisplayText.source(state.source)) + .font(.system(size: isCompact ? 18 : 25, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.6) + + Text(CompanionDisplayText.subtitle( + workspaceName: state.workspaceName, + toolName: state.toolName, + fallback: "Mac" + )) + .font(.system(size: isCompact ? 11 : 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.52)) + .lineLimit(1) + } + + WatchStatusBadge(status: state.status) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 8) + .padding(.top, isCompact ? 24 : 14) + .padding(.bottom, isCompact ? 8 : 10) + } + } +} + +private struct WatchMessagePage: View { + let state: CompanionStatePayload + @EnvironmentObject private var connection: WatchConnection + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + WatchPageTitle(title: messageTitle, systemImage: messageIcon, color: messageColor) + + Text(primaryText) + .font(.system(size: 18, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(5) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 5) { + WatchChip(text: CompanionDisplayText.workspace(state.workspaceName) ?? "工作区", icon: "folder") + if let toolText = CompanionDisplayText.tool(state.toolName) { + WatchChip(text: toolText, icon: "hammer") + } + } + + if let question = state.question, !question.options.isEmpty { + WatchQuestionOptions(question: question) + } + } + .padding(10) + } + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.white.opacity(0.045)) + .padding(.horizontal, 5) + ) + } + + private var primaryText: String { + if let question = state.question?.question { + return question + } + if let message = CompanionDisplayText.message(state.messages.last?.text) { + return message + } + return "当前没有新的消息" + } + + private var messageTitle: String { + state.pendingAction == .question ? "需要回答" : "当前消息" + } + + private var messageIcon: String { + state.pendingAction == .question ? "questionmark.bubble.fill" : "text.bubble.fill" + } + + private var messageColor: Color { + state.pendingAction == .question ? .orange : .blue + } +} + +private struct WatchActionsPage: View { + let state: CompanionStatePayload + @EnvironmentObject private var connection: WatchConnection + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + WatchPageTitle(title: "快捷操作", systemImage: "bolt.fill", color: .green) + + Spacer(minLength: 0) + + WatchActionStrip(state: state) + + Spacer(minLength: 0) + } + .padding(10) + } +} + +private struct WatchActivityPage: View { + let messages: [CompanionMessagePreview] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + WatchPageTitle(title: "最近动态", systemImage: "waveform.path.ecg", color: .purple) + WatchRecentView(messages: messages) + } + .padding(10) + } + } +} + +private struct WatchPageTitle: View { + let title: String + let systemImage: String + let color: Color + + var body: some View { + Label(title, systemImage: systemImage) + .font(.system(size: 13, weight: .black, design: .rounded)) + .foregroundStyle(color) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(color.opacity(0.16), in: Capsule()) + } +} + +private struct WatchIslandHeader: View { + let state: CompanionStatePayload + + var body: some View { + HStack(spacing: 8) { + SharedMascotView(source: state.source, status: MascotAgentStatus(state.status.rawValue), size: 34) + + VStack(alignment: .leading, spacing: 2) { + Text(CompanionDisplayText.source(state.source)) + .font(.system(size: 15, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.72) + + Text(CompanionDisplayText.subtitle( + workspaceName: state.workspaceName, + toolName: state.toolName, + fallback: "Mac" + )) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.58)) + .lineLimit(1) + } + + Spacer(minLength: 0) + WatchStatusBadge(status: state.status, compact: true) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .background(Color.white.opacity(0.055), in: Capsule()) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + } +} + +private struct WatchQuestionOptions: View { + let question: CompanionQuestionPayload + @EnvironmentObject private var connection: WatchConnection + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 5) { + if let header = question.header, !header.isEmpty { + Text(header) + .font(.system(size: 11, weight: .black, design: .rounded)) + .foregroundStyle(.blue) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.blue.opacity(0.18), in: Capsule()) + } + + Text("\(question.index + 1)/\(question.total)") + .font(.system(size: 11, weight: .black, design: .rounded)) + .foregroundStyle(.white.opacity(0.62)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.white.opacity(0.10), in: Capsule()) + } + + ForEach(Array(question.options.prefix(4).enumerated()), id: \.offset) { index, option in + Button { + connection.send(.answerQuestion, answer: option) + } label: { + HStack(spacing: 7) { + Text("\(index + 1)") + .font(.system(size: 12, weight: .black, design: .rounded)) + .foregroundStyle(.blue) + .frame(width: 18, alignment: .leading) + + Text(option) + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.90)) + .lineLimit(2) + + Spacer(minLength: 0) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + } + } + } +} + +private struct WatchSessionCard: View { + let state: CompanionStatePayload + @EnvironmentObject private var connection: WatchConnection + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 8) { + SharedMascotView(source: state.source, status: MascotAgentStatus(state.status.rawValue), size: 40) + + VStack(alignment: .leading, spacing: 2) { + Text(CompanionDisplayText.source(state.source)) + .font(.system(size: 18, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.65) + + Text(CompanionDisplayText.subtitle( + workspaceName: state.workspaceName, + toolName: state.toolName, + fallback: "Mac" + )) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.52)) + .lineLimit(1) + } + + Spacer(minLength: 0) + WatchStatusBadge(status: state.status) + } + + Text(primaryText) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.88)) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 5) { + WatchChip(text: CompanionDisplayText.workspace(state.workspaceName) ?? "工作区", icon: "folder") + if let toolText = CompanionDisplayText.tool(state.toolName) { + WatchChip(text: toolText, icon: "hammer") + } + } + + if let question = state.question, !question.options.isEmpty { + WatchQuestionOptions(question: question) + } + } + .padding(10) + .background(Color.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(statusColor(state.status).opacity(0.38), lineWidth: 1) + ) + } + + private var primaryText: String { + if let question = state.question?.question { + return question + } + if let message = CompanionDisplayText.message(state.messages.last?.text) { + return message + } + return "当前没有新的消息" + } +} + +private struct WatchActionStrip: View { + let state: CompanionStatePayload + @EnvironmentObject private var connection: WatchConnection + + var body: some View { + VStack(spacing: 6) { + Button { + connection.send(.focus) + } label: { + WatchActionLabel(title: "打开 Mac", systemImage: "arrow.up.forward.app.fill", color: .green) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + + if state.pendingAction == .approval { + HStack(spacing: 6) { + Button { + connection.send(.approveCurrentPermission) + } label: { + WatchActionLabel(title: "批准", systemImage: "checkmark", color: .orange) + } + .buttonStyle(.plain) + + Button { + connection.send(.denyCurrentPermission) + } label: { + WatchActionLabel(title: "拒绝", systemImage: "xmark", color: .red) + } + .buttonStyle(.plain) + } + } else if state.pendingAction == .question { + Button { + connection.send(.focus) + } label: { + WatchActionLabel(title: "去 iPhone 回答", systemImage: "questionmark.bubble.fill", color: .blue) + } + .buttonStyle(.plain) + } + } + } +} + +private struct WatchRecentView: View { + let messages: [CompanionMessagePreview] + + var body: some View { + let recent = messages.suffix(2) + + if !recent.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("最近动态") + .font(.system(size: 13, weight: .black, design: .rounded)) + .foregroundStyle(.white.opacity(0.56)) + + ForEach(Array(recent.enumerated()), id: \.offset) { _, message in + HStack(alignment: .top, spacing: 6) { + Text(message.role.label) + .font(.system(size: 10, weight: .black, design: .rounded)) + .foregroundStyle(.black) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.white.opacity(message.role == .user ? 0.9 : 0.22), in: Capsule()) + + Text(CompanionDisplayText.message(message.text) ?? message.text) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.72)) + .lineLimit(4) + } + } + } + .padding(10) + .background(Color.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } +} + +private struct WatchEmptyView: View { + let error: String? + + var body: some View { + GeometryReader { proxy in + let isCompact = proxy.size.height < 220 + ScrollView { + VStack(alignment: .center, spacing: isCompact ? 8 : 11) { + HStack(spacing: 7) { + SharedMascotView(source: "codex", status: .idle, size: isCompact ? 26 : 30) + Text("Code Island") + .font(.system(size: isCompact ? 14 : 15, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.white.opacity(0.055), in: Capsule()) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + + SharedMascotView(source: "codex", status: .idle, size: isCompact ? 48 : 56) + + Text("等待 iPhone 同步") + .font(.system(size: isCompact ? 15 : 16, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.78) + + Text(error ?? "打开 iPhone 上的 Code Island,并连接 Mac") + .font(.system(size: isCompact ? 10 : 11, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.58)) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.76) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 8) + .padding(.vertical, isCompact ? 4 : 8) + } + } + } +} + +private struct WatchChip: View { + let text: String + let icon: String + + var body: some View { + Label(text, systemImage: icon) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.68)) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.white.opacity(0.08), in: Capsule()) + } +} + +private struct WatchStatusBadge: View { + let status: CompanionStatus + var compact = false + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(statusColor(status)) + .frame(width: compact ? 7 : 8, height: compact ? 7 : 8) + if !compact { + Text(status.shortLabel) + .font(.system(size: 11, weight: .black, design: .rounded)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + } + .padding(.horizontal, compact ? 7 : 8) + .padding(.vertical, compact ? 6 : 7) + .background(statusColor(status).opacity(0.16), in: Capsule()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(status.label) + } +} + +private struct WatchActionLabel: View { + let title: String + let systemImage: String + let color: Color + + var body: some View { + Label(title, systemImage: systemImage) + .font(.system(size: 14, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.72) + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, minHeight: 42) + .background(color.opacity(0.34), in: Capsule()) + .overlay( + Capsule() + .stroke(color.opacity(0.72), lineWidth: 1) + ) + .accessibilityLabel(title) + } +} + +private func statusColor(_ status: CompanionStatus) -> Color { + switch status { + case .idle: + return Color(red: 0.62, green: 0.68, blue: 0.76) + case .processing, .running: + return Color(red: 0.25, green: 0.86, blue: 0.38) + case .waitingApproval, .waitingQuestion: + return Color.orange + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchStatusWidget.swift b/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchStatusWidget.swift new file mode 100644 index 0000000..73fc9c0 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchStatusWidget.swift @@ -0,0 +1,137 @@ +import SwiftUI +import WidgetKit + +struct CodeIslandWatchStatusWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "CodeIslandWatchStatusWidget", provider: CodeIslandWatchTimelineProvider()) { entry in + CodeIslandWatchWidgetView(entry: entry) + } + .configurationDisplayName("Code Island") + .description("显示当前 Mac 会话状态。") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} + +struct CodeIslandWatchTimelineEntry: TimelineEntry { + let date: Date + let state: CompanionStatePayload? +} + +struct CodeIslandWatchTimelineProvider: TimelineProvider { + func placeholder(in context: Context) -> CodeIslandWatchTimelineEntry { + CodeIslandWatchTimelineEntry(date: Date(), state: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (CodeIslandWatchTimelineEntry) -> Void) { + completion(CodeIslandWatchTimelineEntry(date: Date(), state: WatchStateStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = CodeIslandWatchTimelineEntry(date: Date(), state: WatchStateStore.load()) + completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(5 * 60)))) + } +} + +private struct CodeIslandWatchWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: CodeIslandWatchTimelineEntry + + var body: some View { + switch family { + case .accessoryCircular: + CircularWidgetView(state: entry.state) + case .accessoryRectangular: + RectangularWidgetView(state: entry.state) + case .accessoryInline: + InlineWidgetView(state: entry.state) + default: + RectangularWidgetView(state: entry.state) + } + } +} + +private struct CircularWidgetView: View { + let state: CompanionStatePayload? + + var body: some View { + VStack(spacing: 2) { + SharedMascotView(source: state?.source ?? "codex", status: status, size: 24) + Text(state?.status.shortLabel ?? "等待") + .font(.system(size: 10, weight: .black, design: .rounded)) + .lineLimit(1) + } + .containerBackground(.fill.tertiary, for: .widget) + } + + private var status: MascotAgentStatus { + MascotAgentStatus(state?.status.rawValue ?? "idle") + } +} + +private struct RectangularWidgetView: View { + let state: CompanionStatePayload? + + var body: some View { + HStack(spacing: 7) { + SharedMascotView(source: state?.source ?? "codex", status: status, size: 30) + + VStack(alignment: .leading, spacing: 1) { + Text(sourceText) + .font(.system(size: 14, weight: .black, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.7) + + Text(messageText) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.65) + } + + Spacer(minLength: 0) + + Circle() + .fill(statusColor(state?.status ?? .idle)) + .frame(width: 8, height: 8) + } + .containerBackground(.fill.tertiary, for: .widget) + } + + private var sourceText: String { + CompanionDisplayText.source(state?.source) + } + + private var messageText: String { + if let question = state?.question?.question { + return question + } + if let message = CompanionDisplayText.message(state?.messages.last?.text) { + return message + } + return state == nil ? "等待同步" : "当前没有新的消息" + } + + private var status: MascotAgentStatus { + MascotAgentStatus(state?.status.rawValue ?? "idle") + } +} + +private struct InlineWidgetView: View { + let state: CompanionStatePayload? + + var body: some View { + Text("\(CompanionDisplayText.source(state?.source)) \(state?.status.shortLabel ?? "等待同步")") + .containerBackground(.fill.tertiary, for: .widget) + } +} + +private func statusColor(_ status: CompanionStatus) -> Color { + switch status { + case .idle: + return Color(red: 0.62, green: 0.68, blue: 0.76) + case .processing, .running: + return Color(red: 0.25, green: 0.86, blue: 0.38) + case .waitingApproval, .waitingQuestion: + return Color.orange + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchWidget.entitlements b/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchWidget.entitlements new file mode 100644 index 0000000..2e95cd8 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.top.fengye.CodeIslandCompanion + + + diff --git a/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchWidgetBundle.swift b/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchWidgetBundle.swift new file mode 100644 index 0000000..a85c909 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchWidget/CodeIslandWatchWidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct CodeIslandWatchWidgetBundle: WidgetBundle { + var body: some Widget { + CodeIslandWatchStatusWidget() + } +} diff --git a/ios/CodeIslandCompanion/CodeIslandWatchWidget/Info.plist b/ios/CodeIslandCompanion/CodeIslandWatchWidget/Info.plist new file mode 100644 index 0000000..ada2f51 --- /dev/null +++ b/ios/CodeIslandCompanion/CodeIslandWatchWidget/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + Code Island + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/CodeIslandCompanion/README.md b/ios/CodeIslandCompanion/README.md new file mode 100644 index 0000000..f29fdb6 --- /dev/null +++ b/ios/CodeIslandCompanion/README.md @@ -0,0 +1,24 @@ +# Code Island Buddy + +This is the Xcode project for the Code Island iPhone, Live Activity, and Apple Watch Buddy. + +For the product overview, setup guide, protocol notes, and screenshots, see: + +- [`../../apple-companion/README.md`](../../apple-companion/README.md) + +## Project Contents + +- `CodeIslandCompanion/` - iPhone app +- `CodeIslandCompanionWidget/` - iPhone Live Activity, Dynamic Island, and StandBy UI +- `CodeIslandWatchApp/` - Apple Watch app +- `CodeIslandWatchWidget/` - watchOS widget +- `Shared/` - shared models, display helpers, and mascot views +- `project.yml` - XcodeGen project definition + +## Open in Xcode + +```bash +cd ios/CodeIslandCompanion +xcodegen generate +open CodeIslandCompanion.xcodeproj +``` diff --git a/ios/CodeIslandCompanion/Shared/CodeIslandActivityAttributes.swift b/ios/CodeIslandCompanion/Shared/CodeIslandActivityAttributes.swift new file mode 100644 index 0000000..2156a59 --- /dev/null +++ b/ios/CodeIslandCompanion/Shared/CodeIslandActivityAttributes.swift @@ -0,0 +1,77 @@ +import ActivityKit +import Foundation + +struct CodeIslandSessionActivityPreview: Codable, Hashable, Identifiable { + var sessionId: String? + var source: String + var status: String + var toolName: String? + var workspaceName: String? + var message: String? + var updatedAt: Date + + var id: String { + sessionId ?? "\(source)-\(workspaceName ?? "session")-\(updatedAt.timeIntervalSince1970)" + } + + var statusLabel: String { + switch status { + case "processing": return "处理" + case "running": return "运行" + case "waitingApproval": return "待批准" + case "waitingQuestion": return "待回答" + default: return "空闲" + } + } + + var sourceLabel: String { + source.isEmpty ? "CodeIsland" : source.uppercased() + } +} + +struct CodeIslandActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var sequence: UInt64 + var source: String + var status: String + var toolName: String? + var workspaceName: String? + var message: String? + var pendingAction: String? + var questionText: String? + var questionHeader: String? + var questionProgress: String? + var sessions: [CodeIslandSessionActivityPreview] + var updatedAt: Date + + var statusLabel: String { + switch status { + case "processing": return "处理中" + case "running": return "运行中" + case "waitingApproval": return "待批准" + case "waitingQuestion": return "待回答" + default: return "空闲" + } + } + + var sourceLabel: String { + source.isEmpty ? "CodeIsland" : source.uppercased() + } + + var compactStatusLabel: String { + switch status { + case "waitingApproval": return "待批" + case "waitingQuestion": return "待答" + case "processing": return "处理" + case "running": return "运行" + default: return "空闲" + } + } + + var activeSessionCount: Int { + sessions.filter { $0.status != "idle" }.count + } + } + + var sessionId: String? +} diff --git a/ios/CodeIslandCompanion/Shared/CompanionDisplayText.swift b/ios/CodeIslandCompanion/Shared/CompanionDisplayText.swift new file mode 100644 index 0000000..bbe9bf8 --- /dev/null +++ b/ios/CodeIslandCompanion/Shared/CompanionDisplayText.swift @@ -0,0 +1,90 @@ +import Foundation + +enum CompanionDisplayText { + static func source(_ text: String?) -> String { + guard let trimmed = cleaned(text) else { return "CodeIsland" } + + switch trimmed.lowercased() { + case "claude", "claudecode", "clawd": + return "CLAUDE" + case "codex", "openai": + return "CODEX" + case "gemini": + return "GEMINI" + case "cursor": + return "CURSOR" + case "opencode": + return "OPENCODE" + case "qwen": + return "QWEN" + default: + return trimmed.uppercased() + } + } + + static func message(_ text: String?) -> String? { + guard let trimmed = cleaned(text) else { return nil } + + switch trimmed { + case "[Request interrupted by user]", "Request interrupted by user": + return "请求已被你中断" + case "[Request interrupted by user for tool use]", "Request interrupted by user for tool use": + return "工具调用已被你中断" + default: + return trimmed + } + } + + static func tool(_ text: String?) -> String? { + guard let trimmed = cleaned(text) else { return nil } + + switch trimmed.lowercased() { + case "askuserquestion": + return "提问" + case "bash", "shell": + return "终端" + case "read": + return "读取" + case "edit", "write", "multiedit": + return "编辑" + case "grep", "glob", "search": + return "搜索" + case "webfetch", "websearch": + return "网页" + case "todowrite": + return "计划" + case "notebookedit": + return "笔记" + default: + return trimmed + } + } + + static func workspace(_ text: String?) -> String? { + guard let trimmed = cleaned(text) else { return nil } + + switch trimmed.lowercased() { + case "workspace": + return "工作区" + default: + return trimmed + } + } + + static func subtitle(workspaceName: String?, toolName: String?, fallback: String) -> String { + if let workspaceName = workspace(workspaceName) { + return workspaceName + } + if let toolName = tool(toolName) { + return toolName + } + return fallback + } + + private static func cleaned(_ text: String?) -> String? { + guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/ios/CodeIslandCompanion/Shared/SharedMascotView.swift b/ios/CodeIslandCompanion/Shared/SharedMascotView.swift new file mode 100644 index 0000000..927a6f1 --- /dev/null +++ b/ios/CodeIslandCompanion/Shared/SharedMascotView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +private struct MascotSpeedKey: EnvironmentKey { + static let defaultValue: Double = 1.0 +} + +extension EnvironmentValues { + var mascotSpeed: Double { + get { self[MascotSpeedKey.self] } + set { self[MascotSpeedKey.self] = newValue } + } +} + +enum MascotAgentStatus { + case idle + case processing + case running + case waitingApproval + case waitingQuestion + + init(_ rawValue: String) { + switch rawValue { + case "processing": + self = .processing + case "running": + self = .running + case "waitingApproval": + self = .waitingApproval + case "waitingQuestion": + self = .waitingQuestion + default: + self = .idle + } + } +} + +struct SharedMascotView: View { + let source: String + let status: MascotAgentStatus + var size: CGFloat = 27 + + var body: some View { + Group { + switch source.lowercased() { + case "codex": + DexView(status: status, size: size) + case "gemini": + GeminiView(status: status, size: size) + case "cursor": + CursorView(status: status, size: size) + case "trae", "traecn", "traecli": + TraeView(status: status, size: size) + case "copilot": + CopilotView(status: status, size: size) + case "qoder": + QoderView(status: status, size: size) + case "droid": + DroidView(status: status, size: size) + case "codebuddy", "codybuddycn": + BuddyView(status: status, size: size) + case "stepfun": + StepFunView(status: status, size: size) + case "opencode": + OpenCodeView(status: status, size: size) + case "qwen": + QwenView(status: status, size: size) + case "antigravity": + AntiGravityView(status: status, size: size) + case "workbuddy": + WorkBuddyView(status: status, size: size) + case "hermes": + HermesView(status: status, size: size) + case "kimi": + KimiView(status: status, size: size) + case "cline": + ClineView(status: status, size: size) + default: + ClawdView(status: status, size: size) + } + } + .environment(\.mascotSpeed, 1.0) + } +} diff --git a/ios/CodeIslandCompanion/Shared/WatchStateStore.swift b/ios/CodeIslandCompanion/Shared/WatchStateStore.swift new file mode 100644 index 0000000..34bca8f --- /dev/null +++ b/ios/CodeIslandCompanion/Shared/WatchStateStore.swift @@ -0,0 +1,34 @@ +import Foundation + +#if os(watchOS) +enum WatchStateStore { + static let appGroupIdentifier = "group.top.fengye.CodeIslandCompanion" + private static let latestStateKey = "latestCompanionState" + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + static func save(_ state: CompanionStatePayload) { + guard let data = try? encoder.encode(state) else { return } + defaults.set(data, forKey: latestStateKey) + } + + static func load() -> CompanionStatePayload? { + guard let data = defaults.data(forKey: latestStateKey) else { return nil } + return try? decoder.decode(CompanionStatePayload.self, from: data) + } + + private static var defaults: UserDefaults { + UserDefaults(suiteName: appGroupIdentifier) ?? .standard + } +} +#endif diff --git a/ios/CodeIslandCompanion/project.yml b/ios/CodeIslandCompanion/project.yml new file mode 100644 index 0000000..5454350 --- /dev/null +++ b/ios/CodeIslandCompanion/project.yml @@ -0,0 +1,184 @@ +name: CodeIslandCompanion +options: + bundleIdPrefix: top.fengye + deploymentTarget: + iOS: "17.0" + watchOS: "10.0" +settings: + base: + SWIFT_VERSION: "5.0" + DEVELOPMENT_TEAM: "6SLN844AVD" + CODE_SIGN_STYLE: Automatic + CURRENT_PROJECT_VERSION: "4" + MARKETING_VERSION: "1.0.0" +targets: + CodeIslandCompanion: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: CodeIslandCompanion + - path: Shared + - path: ../../Sources/CodeIsland/AntiGravityView.swift + - path: ../../Sources/CodeIsland/BuddyView.swift + - path: ../../Sources/CodeIsland/ClineView.swift + - path: ../../Sources/CodeIsland/CopilotView.swift + - path: ../../Sources/CodeIsland/CursorView.swift + - path: ../../Sources/CodeIsland/DexView.swift + - path: ../../Sources/CodeIsland/DroidView.swift + - path: ../../Sources/CodeIsland/GeminiView.swift + - path: ../../Sources/CodeIsland/HermesView.swift + - path: ../../Sources/CodeIsland/KimiView.swift + - path: ../../Sources/CodeIsland/OpenCodeView.swift + - path: ../../Sources/CodeIsland/PixelCharacterView.swift + - path: ../../Sources/CodeIsland/QoderView.swift + - path: ../../Sources/CodeIsland/QwenView.swift + - path: ../../Sources/CodeIsland/StepFunView.swift + - path: ../../Sources/CodeIsland/TraeView.swift + - path: ../../Sources/CodeIsland/WorkBuddyView.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: top.fengye.CodeIslandCompanion + INFOPLIST_FILE: CodeIslandCompanion/Info.plist + TARGETED_DEVICE_FAMILY: "1" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: NO + dependencies: + - target: CodeIslandCompanionWidget + embed: true + - target: CodeIslandWatchApp + embed: true + - sdk: AppIntents.framework + - sdk: WatchConnectivity.framework + CodeIslandCompanionWidget: + type: app-extension + platform: iOS + deploymentTarget: "17.0" + sources: + - path: CodeIslandCompanionWidget + - path: Shared + - path: ../../Sources/CodeIsland/AntiGravityView.swift + - path: ../../Sources/CodeIsland/BuddyView.swift + - path: ../../Sources/CodeIsland/ClineView.swift + - path: ../../Sources/CodeIsland/CopilotView.swift + - path: ../../Sources/CodeIsland/CursorView.swift + - path: ../../Sources/CodeIsland/DexView.swift + - path: ../../Sources/CodeIsland/DroidView.swift + - path: ../../Sources/CodeIsland/GeminiView.swift + - path: ../../Sources/CodeIsland/HermesView.swift + - path: ../../Sources/CodeIsland/KimiView.swift + - path: ../../Sources/CodeIsland/OpenCodeView.swift + - path: ../../Sources/CodeIsland/PixelCharacterView.swift + - path: ../../Sources/CodeIsland/QoderView.swift + - path: ../../Sources/CodeIsland/QwenView.swift + - path: ../../Sources/CodeIsland/StepFunView.swift + - path: ../../Sources/CodeIsland/TraeView.swift + - path: ../../Sources/CodeIsland/WorkBuddyView.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: top.fengye.CodeIslandCompanion.Widget + INFOPLIST_FILE: CodeIslandCompanionWidget/Info.plist + TARGETED_DEVICE_FAMILY: "1" + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: NO + SKIP_INSTALL: YES + CodeIslandWatchApp: + type: application + platform: watchOS + deploymentTarget: "10.0" + sources: + - path: CodeIslandWatchApp + - path: CodeIslandCompanion/CompanionModels.swift + - path: Shared/CompanionDisplayText.swift + - path: Shared/SharedMascotView.swift + - path: Shared/WatchStateStore.swift + - path: ../../Sources/CodeIsland/AntiGravityView.swift + - path: ../../Sources/CodeIsland/BuddyView.swift + - path: ../../Sources/CodeIsland/ClineView.swift + - path: ../../Sources/CodeIsland/CopilotView.swift + - path: ../../Sources/CodeIsland/CursorView.swift + - path: ../../Sources/CodeIsland/DexView.swift + - path: ../../Sources/CodeIsland/DroidView.swift + - path: ../../Sources/CodeIsland/GeminiView.swift + - path: ../../Sources/CodeIsland/HermesView.swift + - path: ../../Sources/CodeIsland/KimiView.swift + - path: ../../Sources/CodeIsland/OpenCodeView.swift + - path: ../../Sources/CodeIsland/PixelCharacterView.swift + - path: ../../Sources/CodeIsland/QoderView.swift + - path: ../../Sources/CodeIsland/QwenView.swift + - path: ../../Sources/CodeIsland/StepFunView.swift + - path: ../../Sources/CodeIsland/TraeView.swift + - path: ../../Sources/CodeIsland/WorkBuddyView.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: top.fengye.CodeIslandCompanion.watchkitapp + INFOPLIST_FILE: CodeIslandWatchApp/Info.plist + CODE_SIGN_ENTITLEMENTS: CodeIslandWatchApp/CodeIslandWatchApp.entitlements + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: NO + dependencies: + - target: CodeIslandWatchWidget + embed: true + - sdk: WatchConnectivity.framework + CodeIslandWatchWidget: + type: app-extension + platform: watchOS + deploymentTarget: "10.0" + sources: + - path: CodeIslandWatchWidget + - path: CodeIslandCompanion/CompanionModels.swift + - path: Shared/CompanionDisplayText.swift + - path: Shared/SharedMascotView.swift + - path: Shared/WatchStateStore.swift + - path: ../../Sources/CodeIsland/AntiGravityView.swift + - path: ../../Sources/CodeIsland/BuddyView.swift + - path: ../../Sources/CodeIsland/ClineView.swift + - path: ../../Sources/CodeIsland/CopilotView.swift + - path: ../../Sources/CodeIsland/CursorView.swift + - path: ../../Sources/CodeIsland/DexView.swift + - path: ../../Sources/CodeIsland/DroidView.swift + - path: ../../Sources/CodeIsland/GeminiView.swift + - path: ../../Sources/CodeIsland/HermesView.swift + - path: ../../Sources/CodeIsland/KimiView.swift + - path: ../../Sources/CodeIsland/OpenCodeView.swift + - path: ../../Sources/CodeIsland/PixelCharacterView.swift + - path: ../../Sources/CodeIsland/QoderView.swift + - path: ../../Sources/CodeIsland/QwenView.swift + - path: ../../Sources/CodeIsland/StepFunView.swift + - path: ../../Sources/CodeIsland/TraeView.swift + - path: ../../Sources/CodeIsland/WorkBuddyView.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: top.fengye.CodeIslandCompanion.watchkitapp.Widget + INFOPLIST_FILE: CodeIslandWatchWidget/Info.plist + CODE_SIGN_ENTITLEMENTS: CodeIslandWatchWidget/CodeIslandWatchWidget.entitlements + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: NO + SKIP_INSTALL: YES + CodeIslandCompanionUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "17.0" + sources: + - path: CodeIslandCompanionUITests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: top.fengye.CodeIslandCompanionUITests + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: CodeIslandCompanion + TARGETED_DEVICE_FAMILY: "1" + dependencies: + - target: CodeIslandCompanion +schemes: + CodeIslandCompanion: + build: + targets: + CodeIslandCompanion: all + CodeIslandCompanionWidget: all + CodeIslandWatchApp: all + CodeIslandWatchWidget: all + CodeIslandCompanionUITests: [test] + run: + config: Debug + test: + config: Debug + targets: + - CodeIslandCompanionUITests diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index 13c29ed..5d7424d 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -224,18 +224,29 @@ echo "==> Creating DMG" # Remove previous DMG if exists rm -f "$OUTPUT_DMG" -create-dmg \ - --volname "CodeIsland ${VERSION}" \ - --window-pos 200 120 \ - --window-size 600 400 \ - --icon-size 100 \ - --icon "CodeIsland.app" 175 190 \ - --hide-extension "CodeIsland.app" \ - --app-drop-link 425 190 \ - --no-internet-enable \ - --sandbox-safe \ - "$OUTPUT_DMG" \ - "$STAGING_DIR/" +if command -v create-dmg >/dev/null 2>&1; then + create-dmg \ + --volname "CodeIsland ${VERSION}" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "CodeIsland.app" 175 190 \ + --hide-extension "CodeIsland.app" \ + --app-drop-link 425 190 \ + --no-internet-enable \ + --sandbox-safe \ + "$OUTPUT_DMG" \ + "$STAGING_DIR/" +else + echo "==> create-dmg not found — using hdiutil fallback" + ln -sfn /Applications "$STAGING_DIR/Applications" + hdiutil create \ + -volname "CodeIsland ${VERSION}" \ + -srcfolder "$STAGING_DIR" \ + -format UDZO \ + -ov \ + "$OUTPUT_DMG" +fi # Codesign the DMG container itself. Without this `spctl --assess` reports # "no usable signature" on the dmg even when the inner .app is properly diff --git a/scripts/check-companion-ui-regressions.sh b/scripts/check-companion-ui-regressions.sh new file mode 100755 index 0000000..e00a3e4 --- /dev/null +++ b/scripts/check-companion-ui-regressions.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +failures=0 + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + failures=$((failures + 1)) +} + +expect_present() { + local label="$1" + local pattern="$2" + local file="$3" + if ! grep -Fq "$pattern" "$file"; then + fail "$label missing in $file" + else + printf 'ok: %s\n' "$label" + fi +} + +expect_absent() { + local label="$1" + local pattern="$2" + local file="$3" + if grep -Fq "$pattern" "$file"; then + fail "$label found in $file" + else + printf 'ok: %s\n' "$label" + fi +} + +expect_absent \ + "idle notch bar must not hardcode Claude" \ + 'MascotView(source: "claude", status: .idle' \ + "Sources/CodeIsland/NotchPanelView.swift" + +expect_absent \ + "iPhone live card should not duplicate the state mascot" \ + 'PixelMascot(source: state.source, status: state.status, size: 42)' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift" + +expect_absent \ + "iPhone discovery card should not duplicate the compact island mascot" \ + 'PixelMascot(source: "codex", status: connection.browsing ? .processing : .idle, size: 42)' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift" + +expect_present \ + "iPhone compact island keeps one small mascot" \ + 'CompanionMascotView(source: connection.latestState?.source ?? "codex", status: compactStatus, size: 30)' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift" + +expect_present \ + "iPhone StandBy keeps one large mascot" \ + 'CompanionMascotView(source: state.source, status: state.status, size: 78)' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift" + +expect_absent \ + "iPhone app should not use the temporary hand-drawn PixelMascot" \ + 'PixelMascot(' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift" + +expect_present \ + "iPhone mascot router reuses Mac Dex mascot view" \ + 'DexView(status: status, size: size)' \ + "ios/CodeIslandCompanion/Shared/SharedMascotView.swift" + +expect_absent \ + "Live Activity should not use the temporary WidgetPixelMascot" \ + 'WidgetPixelMascot' \ + "ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift" + +expect_present \ + "Live Activity uses shared Mac mascot views" \ + 'SharedMascotView(source: state.source, status: MascotAgentStatus(state.status), size: 20)' \ + "ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift" + +expect_present \ + "iPhone portrait view scrolls as one page" \ + 'ScrollView(.vertical) {' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift" + +expect_present \ + "Live Activity carries question text" \ + 'var questionText: String?' \ + "ios/CodeIslandCompanion/Shared/CodeIslandActivityAttributes.swift" + +expect_absent \ + "iPhone compact bar should not repeat online text" \ + 'Text(active ? "在线"' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift" + +expect_absent \ + "iPhone metadata should not show no-tool placeholder chip" \ + '"无工具调用"' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/ContentView.swift" + +expect_absent \ + "Dynamic Island expanded view should not duplicate question in center region" \ + 'DynamicIslandExpandedRegion(.center)' \ + "ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift" + +expect_present \ + "Dynamic Island expanded trailing uses adaptive status" \ + 'ExpandedTrailingStatus(state: context.state)' \ + "ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift" + +expect_present \ + "Dynamic Island single-session expanded trailing stays dot-only" \ + 'ExpandedStatusDot(state: state)' \ + "ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift" + +expect_absent \ + "Dynamic Island expanded trailing should not use a text badge" \ + 'private struct ExpandedStatusBadge' \ + "ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift" + +expect_present \ + "Dynamic Island expanded status label stays in bottom metadata" \ + 'Text(state.compactStatusLabel)' \ + "ios/CodeIslandCompanion/CodeIslandCompanionWidget/CodeIslandLiveActivityWidget.swift" + +expect_present \ + "core state payload carries question details" \ + 'public let question: AppleCompanionQuestionPayload?' \ + "Sources/CodeIslandCore/AppleCompanionPayload.swift" + +expect_present \ + "iPhone state model carries question details" \ + 'let question: CompanionQuestionPayload?' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/CompanionModels.swift" + +expect_present \ + "iPhone can send selected question answers" \ + 'func sendAnswer(_ answer: String)' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/CompanionConnection.swift" + +expect_present \ + "Mac handles companion question answers" \ + 'func answerCompanionQuestion(_ answer: String)' \ + "Sources/CodeIsland/AppState.swift" + +expect_absent \ + "iPhone BLE central must not be lazy because background restoration needs early registration" \ + 'lazy var centralManager' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/CompanionBluetoothCentral.swift" + +expect_present \ + "iPhone BLE central uses a restoration identifier" \ + 'CBCentralManagerOptionRestoreIdentifierKey' \ + "ios/CodeIslandCompanion/CodeIslandCompanion/CompanionBluetoothCentral.swift" + +if (( failures > 0 )); then + printf '\n%d companion UI/protocol regression check(s) failed.\n' "$failures" >&2 + exit 1 +fi + +printf '\nAll companion UI/protocol regression checks passed.\n' diff --git a/scripts/smoke-companion-ui.sh b/scripts/smoke-companion-ui.sh new file mode 100755 index 0000000..f7bef1c --- /dev/null +++ b/scripts/smoke-companion-ui.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +SIM_NAME="${1:-iPhone 17 Pro}" +PROJECT="ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj" +SCHEME="CodeIslandCompanion" +DERIVED_DATA="$ROOT/.build/CompanionUISmokeDerivedData" +BUNDLE_ID="top.fengye.CodeIslandCompanion" +MODES=(idle interrupted question long) + +UDID="$( + xcrun simctl list devices available | + grep -m 1 " ${SIM_NAME} (" | + sed -E 's/.*\(([A-F0-9-]+)\).*/\1/' +)" + +if [[ -z "${UDID}" ]]; then + printf 'No available simulator named "%s"; falling back to generic iOS build.\n' "$SIM_NAME" >&2 + xcodebuild -project "$PROJECT" -scheme "$SCHEME" -destination 'generic/platform=iOS' build + exit 0 +fi + +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -destination "platform=iOS Simulator,id=${UDID}" \ + -derivedDataPath "$DERIVED_DATA" \ + build + +APP="$DERIVED_DATA/Build/Products/Debug-iphonesimulator/CodeIslandCompanion.app" +if [[ ! -d "$APP" ]]; then + printf 'Built app was not found at %s\n' "$APP" >&2 + exit 1 +fi + +xcrun simctl boot "$UDID" >/dev/null 2>&1 || true +xcrun simctl bootstatus "$UDID" -b +xcrun simctl install "$UDID" "$APP" + +for mode in "${MODES[@]}"; do + SCREENSHOT="$ROOT/.build/companion-ui-${mode}.png" + xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true + xcrun simctl launch "$UDID" "$BUNDLE_ID" -CodeIslandCompanionMockState "$mode" >/dev/null + sleep 2 + xcrun simctl io "$UDID" screenshot "$SCREENSHOT" + printf 'Companion UI smoke screenshot (%s): %s\n' "$mode" "$SCREENSHOT" +done diff --git a/scripts/smoke-companion-watch-ui.sh b/scripts/smoke-companion-watch-ui.sh new file mode 100755 index 0000000..169e6de --- /dev/null +++ b/scripts/smoke-companion-watch-ui.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PROJECT="ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj" +SCHEME="CodeIslandWatchApp" +DERIVED_DATA="$ROOT/.build/CompanionWatchUISmokeDerivedData" +BUNDLE_ID="top.fengye.CodeIslandCompanion.watchkitapp" +PAGES=(status message actions activity) + +if [[ "$#" -gt 0 ]]; then + WATCH_NAMES=("$@") +else + WATCH_NAMES=("Apple Watch SE 3 (40mm)" "Apple Watch Series 11 (46mm)") +fi + +find_watch_udid() { + local name="$1" + xcrun simctl list devices available | + grep -m 1 " ${name} (" | + sed -E 's/.*\(([A-F0-9-]+)\).*/\1/' +} + +for watch_name in "${WATCH_NAMES[@]}"; do + udid="$(find_watch_udid "$watch_name")" + if [[ -z "$udid" ]]; then + printf 'No available watch simulator named "%s"; skipping.\n' "$watch_name" >&2 + continue + fi + + xcrun simctl boot "$udid" >/dev/null 2>&1 || true + xcrun simctl bootstatus "$udid" -b >/dev/null + + xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -destination "platform=watchOS Simulator,id=${udid}" \ + -derivedDataPath "$DERIVED_DATA" \ + build + + app="$DERIVED_DATA/Build/Products/Debug-watchsimulator/CodeIslandWatchApp.app" + if [[ ! -d "$app" ]]; then + printf 'Built watch app was not found at %s\n' "$app" >&2 + exit 1 + fi + + xcrun simctl uninstall "$udid" "$BUNDLE_ID" >/dev/null 2>&1 || true + xcrun simctl install "$udid" "$app" + + slug="$(tr '[:upper:] ()' '[:lower:]---' <<<"$watch_name" | tr -s '-')" + for page in "${PAGES[@]}"; do + screenshot="$ROOT/.build/companion-watch-ui-${slug}-${page}.png" + xcrun simctl terminate "$udid" "$BUNDLE_ID" >/dev/null 2>&1 || true + xcrun simctl launch \ + "$udid" \ + "$BUNDLE_ID" \ + -CodeIslandWatchSmokeState question \ + -CodeIslandWatchSmokePage "$page" >/dev/null + sleep 2 + xcrun simctl io "$udid" screenshot "$screenshot" >/dev/null + printf 'Companion watch UI smoke screenshot (%s, %s): %s\n' "$watch_name" "$page" "$screenshot" + done +done diff --git a/scripts/smoke-companion.sh b/scripts/smoke-companion.sh new file mode 100755 index 0000000..62e4746 --- /dev/null +++ b/scripts/smoke-companion.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +./scripts/check-companion-ui-regressions.sh + +swift test --filter AppleCompanionPayloadTests +swift test --filter AppStateQuestionFlowTests +swift test --filter AppStatePrimarySourceTests +swift build + +xcodebuild \ + -project ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj \ + -scheme CodeIslandCompanion \ + -destination 'generic/platform=iOS' \ + build