diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec15d59..5bd5a192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Added - #327 R1:记忆平面设计锚点。新增 ADR-0008 与 `docs/design/memory-plane-v1.md`,冻结四层所有权、FIFO/LRU/TTL 分工、链式 segment/head/index、召回收据与子 issue DAG。durable-memory 绑定 principal + grant/revocation version + permissionDigest(拒绝自由字符串 scope)。准入硬预算以 UTF-8 bytes 为准,模型 token 只作成本上限。不改变运行时;未接受修订不得被实现 PR 消费。 +- #338:iOS SwiftUI 原生客户端(`mobile/ios`)。桌面控制面 HTTP 带 `Authorization: Bearer `;WebSocket 指数退避重连(1s/2s/4s/8s/16s,最多 5 次)并保留 device token;组织页必须显式点选岗位后才能发指令;配对码限 6 位数字;主机与 boot-token 本地持久化。回合仍在电脑上执行。 - #356: design-only multi-agent branch heads and handoff-carried checkpoint refs (`docs/design/memory-branch-handoff-v1.md`). No silent head clobber; handoff does not copy grants. No runtime. diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 00000000..61a42ab9 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,19 @@ +# Native RoleWeave clients + +Three separate apps. They do not share UI code. + +| App | Stack | Open with | Issue | +| --- | --- | --- | --- | +| `ios/` | SwiftUI, iOS 17 | Xcode | #338 | +| `android/` | Kotlin, Jetpack Compose | Android Studio | #339 | +| `harmony/` | ArkTS, ArkUI API 12 | DevEco Studio | #340 | + +Each app talks to a **running RoleWeave desktop / hosted entry**: + +- `GET /api/mobile/workspace` — org preview +- `POST /phone-link/v1/pair` `{ "code": "123456" }` — pair +- `ws(s)://host/phone-link/phone` — send `command.submit` + +Turns execute on the computer. These apps do not run Agents. + +This Linux sandbox cannot compile iOS, Android, or HarmonyOS binaries. Open each project on the matching OS. diff --git a/mobile/ios/README.md b/mobile/ios/README.md new file mode 100644 index 00000000..ccd14852 --- /dev/null +++ b/mobile/ios/README.md @@ -0,0 +1,11 @@ +# RoleWeave iOS + +Native SwiftUI client. Open `RoleWeave.xcodeproj` in Xcode 15+ on a Mac. + +1. Select an iPhone simulator or device. +2. Set the host (persisted) in 指令 / 设置 to your RoleWeave URL. +3. For the desktop control plane, paste the boot-token; HTTP calls send `Authorization: Bearer ` (`/health` is the only unauthenticated route). +4. Pair with a 6-digit code from the desktop. The socket reconnects with exponential backoff (1s/2s/4s/8s/16s, 5 attempts) and keeps the device token. +5. Select a role on 组织 before sending a command. Turns still run on the computer. + +Requires iOS 17. Does not compile on Linux. diff --git a/mobile/ios/RoleWeave.xcodeproj/project.pbxproj b/mobile/ios/RoleWeave.xcodeproj/project.pbxproj new file mode 100644 index 00000000..90cea81b --- /dev/null +++ b/mobile/ios/RoleWeave.xcodeproj/project.pbxproj @@ -0,0 +1,177 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + A10000000000000000000001 /* RoleWeaveApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoleWeaveApp.swift; sourceTree = ""; }; + A10000000000000000000002 /* PhoneSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneSession.swift; sourceTree = ""; }; + A10000000000000000000003 /* OrgView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrgView.swift; sourceTree = ""; }; + A10000000000000000000004 /* CommandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandView.swift; sourceTree = ""; }; + A10000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A10000000000000000000007 /* PhoneLinkCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneLinkCodec.swift; sourceTree = ""; }; + A20000000000000000000001 /* RoleWeaveApp.swift */ = {isa = PBXBuildFile; fileRef = A10000000000000000000001 /* RoleWeaveApp.swift */; }; + A20000000000000000000002 /* PhoneSession.swift */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* PhoneSession.swift */; }; + A20000000000000000000003 /* OrgView.swift */ = {isa = PBXBuildFile; fileRef = A10000000000000000000003 /* OrgView.swift */; }; + A20000000000000000000004 /* CommandView.swift */ = {isa = PBXBuildFile; fileRef = A10000000000000000000004 /* CommandView.swift */; }; + A20000000000000000000007 /* PhoneLinkCodec.swift */ = {isa = PBXBuildFile; fileRef = A10000000000000000000007 /* PhoneLinkCodec.swift */; }; + A30000000000000000000001 /* RoleWeave */ = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* RoleWeaveApp.swift */, + A10000000000000000000002 /* PhoneSession.swift */, + A10000000000000000000007 /* PhoneLinkCodec.swift */, + A10000000000000000000003 /* OrgView.swift */, + A10000000000000000000004 /* CommandView.swift */, + A10000000000000000000005 /* Info.plist */, + ); + path = RoleWeave; + sourceTree = ""; + }; + A30000000000000000000002 /* Root */ = { + isa = PBXGroup; + children = ( + A30000000000000000000001 /* RoleWeave */, + ); + sourceTree = ""; + }; + A40000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A20000000000000000000001 /* RoleWeaveApp.swift */, + A20000000000000000000002 /* PhoneSession.swift */, + A20000000000000000000007 /* PhoneLinkCodec.swift */, + A20000000000000000000003 /* OrgView.swift */, + A20000000000000000000004 /* CommandView.swift */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A50000000000000000000001 /* RoleWeave */ = { + isa = PBXNativeTarget; + buildConfigurationList = A70000000000000000000002 /* Build */; + buildPhases = ( + A40000000000000000000001 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RoleWeave; + productName = RoleWeave; + productReference = A10000000000000000000006 /* RoleWeave.app */; + productType = "com.apple.product-type.application"; + }; + A10000000000000000000006 /* RoleWeave.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RoleWeave.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A60000000000000000000001 /* Project */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + A50000000000000000000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = A70000000000000000000001 /* Project */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = "zh-Hans"; + hasScannedForEncodings = 0; + knownRegions = ( + en, + "zh-Hans", + Base, + ); + mainGroup = A30000000000000000000002 /* Root */; + productRefGroup = A30000000000000000000002 /* Root */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A50000000000000000000001 /* RoleWeave */, + ); + }; + A80000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A80000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + A80000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = RoleWeave/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = RoleWeave; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.bytefolk.roleweave; + PRODUCT_NAME = RoleWeave; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + A80000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = RoleWeave/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.bytefolk.roleweave; + PRODUCT_NAME = RoleWeave; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + A70000000000000000000001 /* Project */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000001 /* Debug */, + A80000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A70000000000000000000002 /* Build */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000003 /* Debug */, + A80000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + }; + rootObject = A60000000000000000000001 /* Project */; +} diff --git a/mobile/ios/RoleWeave/CommandView.swift b/mobile/ios/RoleWeave/CommandView.swift new file mode 100644 index 00000000..f18e5746 --- /dev/null +++ b/mobile/ios/RoleWeave/CommandView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct CommandView: View { + @EnvironmentObject private var session: PhoneSession + @State private var code = "" + @State private var text = "" + + var body: some View { + NavigationStack { + Form { + Section("电脑") { + TextField("RoleWeave 地址", text: $session.host) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + SecureField("boot-token(桌面控制面)", text: $session.bootToken) + .textInputAutocapitalization(.never) + Text(session.status).foregroundStyle(.secondary) + } + if !session.paired { + Section("配对") { + TextField("6 位配对码", text: $code) + .keyboardType(.numberPad) + .onChange(of: code) { _, newValue in + let digits = newValue.filter(\.isNumber) + code = String(digits.prefix(6)) + } + Button("配对这台电脑") { + Task { await session.pair(code: code) } + } + .disabled(!PhoneLinkCodec.isValidPairCode(code)) + } + } else { + Section("指令") { + if let role = session.selectedRole { + Text("岗位:\(role.name)").foregroundStyle(.secondary) + } else { + Text("请先在组织页点选一个岗位").foregroundStyle(.red) + } + TextField("例如:看一下还开着的 PR", text: $text, axis: .vertical) + .lineLimit(4...8) + Button("发给电脑") { + session.sendCommand(text) + text = "" + } + .disabled(session.selectedRoleId == nil || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + if !session.summary.isEmpty { + Section("结果") { Text(session.summary) } + } + } + .navigationTitle("指令") + } + } +} + +struct SettingsView: View { + @EnvironmentObject private var session: PhoneSession + + var body: some View { + NavigationStack { + List { + LabeledContent("平台", value: "iOS") + LabeledContent("回合", value: "在电脑上执行") + LabeledContent("组织", value: "只读预览") + Section("连接") { + TextField("RoleWeave 地址", text: $session.host) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + SecureField("boot-token", text: $session.bootToken) + .textInputAutocapitalization(.never) + } + } + .navigationTitle("设置") + } + } +} diff --git a/mobile/ios/RoleWeave/Info.plist b/mobile/ios/RoleWeave/Info.plist new file mode 100644 index 00000000..65e9bf69 --- /dev/null +++ b/mobile/ios/RoleWeave/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + zh-Hans + CFBundleDisplayName + RoleWeave + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + RoleWeave + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/mobile/ios/RoleWeave/OrgView.swift b/mobile/ios/RoleWeave/OrgView.swift new file mode 100644 index 00000000..b37845b2 --- /dev/null +++ b/mobile/ios/RoleWeave/OrgView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct OrgView: View { + @EnvironmentObject private var session: PhoneSession + @State private var detail: Role? + + var body: some View { + NavigationStack { + List { + if let error = session.errorMessage { + Text(error).foregroundStyle(.red) + } + if let snapshot = session.snapshot { + Section(snapshot.description) { + ForEach(snapshot.roles) { role in + Button { + session.selectRole(role) + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(role.name).font(.headline) + Text(role.description).font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + if session.selectedRoleId == role.id { + Image(systemName: "checkmark.circle.fill") + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("详情") { detail = role } + } + .contextMenu { + Button("查看详情") { detail = role } + Button("选为发指令岗位") { session.selectRole(role) } + } + } + } + } + } + .navigationTitle(session.snapshot?.name ?? "组织") + .task { await session.loadOrg() } + .refreshable { await session.loadOrg() } + .sheet(item: $detail) { role in + RoleDetailView(role: role) + .environmentObject(session) + } + } + } +} + +struct RoleDetailView: View { + let role: Role + @EnvironmentObject private var session: PhoneSession + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section("职责") { Text(role.description) } + if let tokens = role.budget?.perTask?.tokens { + Section("预算") { Text("每任务 \(tokens) tokens") } + } + Section("说明书") { + Text(role.skillExcerpt.isEmpty ? "没有摘录" : role.skillExcerpt) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle(role.name) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("选为岗位") { + session.selectRole(role) + dismiss() + } + } + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { dismiss() } + } + } + } + } +} diff --git a/mobile/ios/RoleWeave/PhoneLinkCodec.swift b/mobile/ios/RoleWeave/PhoneLinkCodec.swift new file mode 100644 index 00000000..2de88310 --- /dev/null +++ b/mobile/ios/RoleWeave/PhoneLinkCodec.swift @@ -0,0 +1,73 @@ +import Foundation + +enum PhoneLinkCodec { + static let maxReconnectAttempts = 5 + + static func reconnectDelayMs(attempt: Int) -> Int { + let clamped = max(1, min(attempt, maxReconnectAttempts)) + return 1000 * (1 << (clamped - 1)) + } + + static func isValidPairCode(_ code: String) -> Bool { + code.count == 6 && code.unicodeScalars.allSatisfy { CharacterSet.decimalDigits.contains($0) } + } + + static func authorizationValue(bootToken: String) -> String? { + let token = bootToken.trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : "Bearer \(token)" + } + + static func normalizedHost(_ host: String) -> String { + host.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + static func workspaceURL(host: String) -> URL? { + URL(string: normalizedHost(host) + "/api/mobile/workspace") + } + + static func pairURL(host: String) -> URL? { + URL(string: normalizedHost(host) + "/phone-link/v1/pair") + } + + static func socketURL(host: String) -> URL? { + guard let http = URL(string: normalizedHost(host)), + var components = URLComponents(url: http, resolvingAgainstBaseURL: false) else { return nil } + components.scheme = http.scheme?.lowercased() == "https" ? "wss" : "ws" + components.path = "/phone-link/phone" + return components.url + } + + static func helloPayload(deviceToken: String) -> [String: Any] { + ["v": 1, "type": "phone.hello", "deviceToken": deviceToken] + } + + static func commandPayload(text: String, positionId: String, commandId: String) -> [String: Any] { + [ + "v": 1, + "type": "command.submit", + "commandId": commandId, + "text": text, + "positionId": positionId, + ] + } + + static func parseMessage(_ text: String) -> [String: Any]? { + guard let data = text.data(using: .utf8), + let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + body["type"] as? String != nil else { return nil } + return body + } + + static func statusLabel(_ state: String) -> String { + let labels = [ + "accepted": "电脑已接到", + "running": "员工正在处理", + "completed": "完成", + "failed": "失败", + "busy": "该员工正在忙", + "needs_approval": "请在电脑上确认", + ] + return labels[state] ?? state + } +} diff --git a/mobile/ios/RoleWeave/PhoneSession.swift b/mobile/ios/RoleWeave/PhoneSession.swift new file mode 100644 index 00000000..2c947d20 --- /dev/null +++ b/mobile/ios/RoleWeave/PhoneSession.swift @@ -0,0 +1,231 @@ +import Foundation +import Combine + +struct WorkspaceSnapshot: Decodable { + let name: String + let description: String + let owner: String? + let roles: [Role] +} + +struct Role: Decodable, Identifiable, Hashable { + let id: String + let name: String + let description: String + let reportTo: String? + let skillExcerpt: String + let budget: Budget? + + struct Budget: Decodable, Hashable { + let perTask: Limit? + struct Limit: Decodable, Hashable { let tokens: Int? } + } +} + +@MainActor +final class PhoneSession: ObservableObject { + @Published var host: String { + didSet { UserDefaults.standard.set(host, forKey: Self.hostKey) } + } + @Published var bootToken: String { + didSet { UserDefaults.standard.set(bootToken, forKey: Self.bootTokenKey) } + } + @Published var snapshot: WorkspaceSnapshot? + @Published var selectedRoleId: String? + @Published var status = "尚未连接电脑" + @Published var summary = "" + @Published var paired = false + @Published var errorMessage: String? + + private var webSocket: URLSessionWebSocketTask? + private var deviceToken: String? { + didSet { UserDefaults.standard.set(deviceToken, forKey: Self.deviceTokenKey) } + } + private var reconnectAttempt = 0 + private var reconnectTask: Task? + + private static let hostKey = "roleweave.phone.host" + private static let bootTokenKey = "roleweave.phone.bootToken" + private static let deviceTokenKey = "roleweave.phone.deviceToken" + + init() { + host = UserDefaults.standard.string(forKey: Self.hostKey) ?? "http://127.0.0.1:8800" + bootToken = UserDefaults.standard.string(forKey: Self.bootTokenKey) ?? "" + deviceToken = UserDefaults.standard.string(forKey: Self.deviceTokenKey) + } + + var selectedRole: Role? { + snapshot?.roles.first { $0.id == selectedRoleId } + } + + func loadOrg() async { + errorMessage = nil + guard let url = PhoneLinkCodec.workspaceURL(host: host) else { + errorMessage = "主机地址无效" + return + } + do { + let (data, response) = try await URLSession.shared.data(for: authorizedRequest(url: url)) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + errorMessage = httpStatusHint(response) + return + } + snapshot = try JSONDecoder().decode(WorkspaceSnapshot.self, from: data) + if let selectedRoleId, snapshot?.roles.contains(where: { $0.id == selectedRoleId }) != true { + self.selectedRoleId = nil + } + } catch { + errorMessage = "组织预览读不到" + } + } + + func pair(code: String) async { + errorMessage = nil + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard PhoneLinkCodec.isValidPairCode(trimmed) else { + errorMessage = "配对码必须是 6 位数字" + status = "配对失败" + return + } + status = "正在配对…" + guard let url = PhoneLinkCodec.pairURL(host: host) else { + errorMessage = "主机地址无效" + return + } + var request = authorizedRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["code": trimmed]) + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let body = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = body["deviceToken"] as? String else { + status = "配对失败" + errorMessage = httpStatusHint(response) + return + } + deviceToken = token + reconnectAttempt = 0 + connectSocket() + } catch { + status = "配对失败" + } + } + + func sendCommand(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard paired, let webSocket else { + status = "还没连上电脑" + return + } + guard let positionId = selectedRoleId, !positionId.isEmpty else { + status = "请先在组织页点选一个岗位" + return + } + guard !trimmed.isEmpty else { return } + let payload = PhoneLinkCodec.commandPayload( + text: trimmed, + positionId: positionId, + commandId: UUID().uuidString + ) + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + webSocket.send(.string(json)) { [weak self] error in + Task { @MainActor in + self?.status = error == nil ? "已发出,等电脑受理…" : "发送失败" + } + } + } + + func selectRole(_ role: Role) { + selectedRoleId = role.id + } + + private func authorizedRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let value = PhoneLinkCodec.authorizationValue(bootToken: bootToken) { + request.setValue(value, forHTTPHeaderField: "Authorization") + } + return request + } + + private func httpStatusHint(_ response: URLResponse) -> String { + guard let http = response as? HTTPURLResponse else { return "组织预览读不到" } + if http.statusCode == 401 { return "需要 boot-token(401)" } + return "请求失败(\(http.statusCode))" + } + + private func connectSocket() { + guard let deviceToken, + let wsURL = PhoneLinkCodec.socketURL(host: host) else { return } + webSocket?.cancel(with: .goingAway, reason: nil) + let task = URLSession.shared.webSocketTask(with: wsURL) + webSocket = task + task.resume() + let hello = PhoneLinkCodec.helloPayload(deviceToken: deviceToken) + if let data = try? JSONSerialization.data(withJSONObject: hello), + let json = String(data: data, encoding: .utf8) { + task.send(.string(json)) { [weak self] error in + Task { @MainActor in + if error != nil { self?.scheduleReconnect() } + } + } + } + listen(task) + } + + private func listen(_ task: URLSessionWebSocketTask) { + task.receive { [weak self] result in + Task { @MainActor in + guard let self else { return } + switch result { + case .failure: + self.scheduleReconnect() + case .success(.string(let text)): + self.handleMessage(text) + self.listen(task) + default: + self.listen(task) + } + } + } + } + + private func scheduleReconnect() { + reconnectTask?.cancel() + guard deviceToken != nil else { + status = "连接断开" + paired = false + return + } + guard reconnectAttempt < PhoneLinkCodec.maxReconnectAttempts else { + status = "连接断开" + paired = false + return + } + reconnectAttempt += 1 + let waitMs = PhoneLinkCodec.reconnectDelayMs(attempt: reconnectAttempt) + status = "连接断开,正在重连(\(reconnectAttempt)/\(PhoneLinkCodec.maxReconnectAttempts))…" + reconnectTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(waitMs) * 1_000_000) + guard !Task.isCancelled else { return } + await self?.connectSocket() + } + } + + private func handleMessage(_ text: String) { + guard let body = PhoneLinkCodec.parseMessage(text), + let type = body["type"] as? String else { return } + if type == "phone.accepted" { + paired = true + reconnectAttempt = 0 + status = "已连上电脑,可以发指令。" + } else if type == "command.status" { + let state = body["state"] as? String ?? "" + status = PhoneLinkCodec.statusLabel(state) + if let summary = body["summary"] as? String { self.summary = summary } + } + } +} diff --git a/mobile/ios/RoleWeave/RoleWeaveApp.swift b/mobile/ios/RoleWeave/RoleWeaveApp.swift new file mode 100644 index 00000000..571998ac --- /dev/null +++ b/mobile/ios/RoleWeave/RoleWeaveApp.swift @@ -0,0 +1,26 @@ +import SwiftUI + +@main +struct RoleWeaveApp: App { + @StateObject private var session = PhoneSession() + + var body: some Scene { + WindowGroup { + RootTabView() + .environmentObject(session) + } + } +} + +struct RootTabView: View { + var body: some View { + TabView { + OrgView() + .tabItem { Label("组织", systemImage: "person.3") } + CommandView() + .tabItem { Label("指令", systemImage: "paperplane") } + SettingsView() + .tabItem { Label("设置", systemImage: "gearshape") } + } + } +}