From eea19dc75b67e16dac611f31e070b8bbeb3a193f Mon Sep 17 00:00:00 2001 From: dazer1234 <47606394+dazer1234@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:12:12 +0200 Subject: [PATCH] Fix single-host and cross-host connection stability --- CHANGELOG.md | 8 + README.md | 4 +- docs/IOS_INSTALL.md | 2 +- docs/RELEASE_0.7.0.2.md | 34 ++++ ios/CodexDeckMobile/Models/RelayModels.swift | 56 +++++++ .../Networking/RelayNodeConnection.swift | 6 +- .../Store/DashboardStore.swift | 92 +++++++++-- ios/CodexDeckMobile/Views/DashboardView.swift | 2 + .../Views/MicroDeviceView.swift | 21 ++- .../MobileMergeTests.swift | 148 ++++++++++++++++++ ios/CodexDeckShared/CodexWidgetState.swift | 8 +- ios/CodexDeckWidgets/CodexDeckWidgets.swift | 35 +++-- package-lock.json | 4 +- package.json | 2 +- src/codex-relay-client.ts | 12 +- src/control-target.ts | 8 + src/controller.ts | 12 +- src/relay-protocol.ts | 40 +++++ static/manifest.json | 2 +- test/control-target.test.ts | 14 +- test/relay.test.ts | 58 ++++++- test/release-docs.test.ts | 2 +- 22 files changed, 519 insertions(+), 51 deletions(-) create mode 100644 docs/RELEASE_0.7.0.2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 16757ce..8cd02c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.7.0.2 - 2026-07-22 + +- Normalize remote snapshot timestamps to local receipt time so ordinary Mac/Windows clock differences cannot hide working, selected, approval, or usage state. +- Reset a stale opposite-platform control target when the plugin starts without a configured second host, while preserving intentional remote targeting during temporary relay outages. +- Keep unique last-known tasks visible when one iPhone node is offline, clearly mark them offline in the app and widgets, and prevent offline agent keys from dispatching commands. +- Prefer the live connection when duplicate iPhone profiles authenticate as the same computer, including command delivery and displayed connection health. +- Added regression coverage for Mac-only, Windows-only, mixed online/offline, duplicate-profile, and cross-host clock-skew behavior. + ## 0.7.0.1 - 2026-07-22 - Fixed one cross-host Codex task appearing twice when a new Windows or Mac thread transitioned from its temporary renderer ID to its stable rollout ID. diff --git a/README.md b/README.md index fbf83a1..7ec5d7c 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,8 @@ npm run audit:release `npm run release:prepare` creates a versioned local release-candidate directory with the plugin package, Windows launcher ZIP, and SHA-256 checksums. The macOS ZIP must be created on macOS with `scripts/package-macos-release.sh` so executable bits survive; pass that ZIP to `scripts/prepare-release.ps1 -MacArchivePath ...`. For a four-component Stream Deck hotfix version, set -`CODEX_DECK_RELEASE_VERSION=0.7.0.1` while running the macOS packager and pass -`-ReleaseVersion 0.7.0.1` to `prepare-release.ps1`. The npm package keeps its +`CODEX_DECK_RELEASE_VERSION=0.7.0.2` while running the macOS packager and pass +`-ReleaseVersion 0.7.0.2` to `prepare-release.ps1`. The npm package keeps its SemVer-compatible prerelease form. Nothing is published automatically. See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs/IOS_INSTALL.md b/docs/IOS_INSTALL.md index 3387865..68a3ace 100644 --- a/docs/IOS_INSTALL.md +++ b/docs/IOS_INSTALL.md @@ -34,7 +34,7 @@ For a released build, either download **Source code (zip)** from that GitHub release and extract it, or clone the matching tag in Terminal: ```zsh -git clone --branch v0.7.0.1 --depth 1 https://github.com/dazer1234/codex-stream-deck.git +git clone --branch v0.7.0.2 --depth 1 https://github.com/dazer1234/codex-stream-deck.git cd codex-stream-deck ``` diff --git a/docs/RELEASE_0.7.0.2.md b/docs/RELEASE_0.7.0.2.md new file mode 100644 index 0000000..5b2907d --- /dev/null +++ b/docs/RELEASE_0.7.0.2.md @@ -0,0 +1,34 @@ +# Codex Deck v0.7.0.2 + +This focused stability hotfix improves single-computer and mixed Mac/Windows +operation. It does not include the in-development task timer or StandBy +dashboard work. + +## Fixes + +- Normalizes remote timestamps when a snapshot arrives, preventing normal clock + differences between Mac and Windows from hiding working, selected, approval, + context, or usage state. +- Returns controls to the local computer when a previously configured second + host has been removed. A configured but temporarily offline relay still keeps + the user's explicit remote selection. +- Keeps unique last-known tasks visible on iPhone when one computer disconnects, + marks them offline in the app and widgets, and blocks taps that cannot be + delivered. +- Routes iPhone commands through the healthy connection when duplicate profiles + authenticate as the same computer. + +## Downloads + +- Stream Deck: `com.simeo.codex-deck.streamDeckPlugin` +- Windows launcher: `codex-deck-launcher-windows-v0.7.0.2.zip` +- macOS launcher: `codex-deck-launcher-macos-v0.7.0.2.zip` +- iPhone source: use the Source code archive or clone tag `v0.7.0.2`. +- Checksums: `SHA256SUMS.txt` + +Existing v0.7.0 and v0.7.0.1 launcher/watcher installations remain compatible. +Stream Deck users only need to install the updated plugin. Codex itself does not +need to restart. + +Codex Deck is an independent community project and is not made, supported, or +endorsed by OpenAI or Elgato. diff --git a/ios/CodexDeckMobile/Models/RelayModels.swift b/ios/CodexDeckMobile/Models/RelayModels.swift index 43db6cf..ac5c1f4 100644 --- a/ios/CodexDeckMobile/Models/RelayModels.swift +++ b/ios/CodexDeckMobile/Models/RelayModels.swift @@ -120,6 +120,62 @@ struct HostSnapshot: Codable, Hashable, Sendable { let host: CodexHost let observedAt: Double let snapshot: MicroSnapshot + + func normalizedToReceiptTime(_ receivedAt: Double) -> HostSnapshot { + guard receivedAt.isFinite, receivedAt > 0, observedAt.isFinite, observedAt > 0 else { + return self + } + let offset = receivedAt - observedAt + func shifted(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0 else { return value } + return max(1, value + offset) + } + let normalizedUsage = snapshot.usage.map { usage in + UsageSnapshot( + windows: usage.windows.map { window in + UsageWindow( + id: window.id, + kind: window.kind, + usedPercent: window.usedPercent, + remainingPercent: window.remainingPercent, + windowDurationMins: window.windowDurationMins, + resetsAt: shifted(window.resetsAt)) + }, + observedAt: shifted(usage.observedAt) ?? receivedAt, + resetCreditsAvailable: usage.resetCreditsAvailable, + resetCreditsApplicable: usage.resetCreditsApplicable) + } + return HostSnapshot( + host: host, + observedAt: receivedAt, + snapshot: MicroSnapshot( + slots: snapshot.slots.map { slot in + AgentSlot( + id: slot.id, + threadKey: slot.threadKey, + title: slot.title, + status: slot.status, + selected: slot.selected, + activityAt: shifted(slot.activityAt), + ownedByHost: slot.ownedByHost, + contextUsedPercent: slot.contextUsedPercent) + }, + activeThreadKey: snapshot.activeThreadKey, + activeThreadTitle: snapshot.activeThreadTitle, + layout: snapshot.layout, + agentSource: snapshot.agentSource, + lightingAutoOff: snapshot.lightingAutoOff, + theme: snapshot.theme, + usage: normalizedUsage, + hostSessions: snapshot.hostSessions?.map { session in + HostSessionPresence( + threadId: session.threadId, + activityAt: shifted(session.activityAt) ?? receivedAt, + status: session.status, + completionRevision: session.completionRevision, + contextUsedPercent: session.contextUsedPercent) + })) + } } enum ThreadIdentity { diff --git a/ios/CodexDeckMobile/Networking/RelayNodeConnection.swift b/ios/CodexDeckMobile/Networking/RelayNodeConnection.swift index 70bd85d..821306e 100644 --- a/ios/CodexDeckMobile/Networking/RelayNodeConnection.swift +++ b/ios/CodexDeckMobile/Networking/RelayNodeConnection.swift @@ -171,9 +171,11 @@ final class RelayNodeConnection: RelayNodeConnecting { status.requiresRepair = false publish(state: .ready, detail: nil) case .snapshot(let snapshot): + let receivedAt = Date() status.host = snapshot.host - status.snapshot = snapshot - status.lastSnapshotReceivedAt = .now + status.snapshot = snapshot.normalizedToReceiptTime( + receivedAt.timeIntervalSince1970 * 1_000) + status.lastSnapshotReceivedAt = receivedAt status.requiresRepair = false publish(state: .ready, detail: nil) case .health(let host, let reason, _): diff --git a/ios/CodexDeckMobile/Store/DashboardStore.swift b/ios/CodexDeckMobile/Store/DashboardStore.swift index e10fd07..8b59d56 100644 --- a/ios/CodexDeckMobile/Store/DashboardStore.swift +++ b/ios/CodexDeckMobile/Store/DashboardStore.swift @@ -110,18 +110,63 @@ final class DashboardStore { publishWidgetState() } - var snapshots: [HostSnapshot] { - let live = nodes.values.filter { $0.state == .ready || $0.state == .degraded } - .compactMap(\.snapshot) - let snapshots = live.isEmpty ? nodes.values.compactMap(\.snapshot) : live - return Dictionary(grouping: snapshots, by: \.host.hostId) + private var liveSnapshots: [HostSnapshot] { + latestSnapshots(nodes.compactMap { profileID, status in + guard connections[profileID] != nil, + status.state == .ready || status.state == .degraded + else { return nil } + return status.snapshot + }) + } + + private func latestSnapshots(_ values: [HostSnapshot]) -> [HostSnapshot] { + Dictionary(grouping: values, by: \.host.hostId) .values .compactMap { $0.max(by: { $0.observedAt < $1.observedAt }) } } + private func connectionPriority(_ state: NodeConnectionState) -> Int { + switch state { + case .ready: 3 + case .degraded: 2 + case .connecting: 1 + case .offline: 0 + } + } + + var snapshots: [HostSnapshot] { + liveSnapshots.isEmpty ? latestSnapshots(nodes.values.compactMap(\.snapshot)) : liveSnapshots + } + var agents: [RoutedAgent] { - MobileMerge.agents( - from: snapshots, acknowledgedCompletions: acknowledgedCompletionRevisions) + let live = liveSnapshots + guard !live.isEmpty else { + return MobileMerge.agents( + from: snapshots, acknowledgedCompletions: acknowledgedCompletionRevisions) + } + let liveAgents = MobileMerge.agents( + from: live, acknowledgedCompletions: acknowledgedCompletionRevisions) + let liveIdentities = Set(liveAgents.map { ThreadIdentity.canonical($0.threadKey) }) + let liveHostIDs = Set(live.map { $0.host.hostId }) + let cached = latestSnapshots( + nodes.values.compactMap(\.snapshot).filter { !liveHostIDs.contains($0.host.hostId) }) + let cachedOnly = MobileMerge.agents( + from: cached, acknowledgedCompletions: acknowledgedCompletionRevisions + ).filter { !liveIdentities.contains(ThreadIdentity.canonical($0.threadKey)) } + return Array((liveAgents + cachedOnly).prefix(6)).enumerated().map { index, agent in + RoutedAgent( + id: index, + threadKey: agent.threadKey, + title: agent.title, + status: agent.status, + selected: agent.selected, + activityAt: agent.activityAt, + host: agent.host, + sourceSlot: agent.sourceSlot, + originPlatform: agent.originPlatform, + ownedByHost: agent.ownedByHost, + contextUsedPercent: agent.contextUsedPercent) + } } var mobileAgentPlacements: [MobileAgentPlacement] { (0..<6).map { position in @@ -142,7 +187,9 @@ final class DashboardStore { } } var connectedCount: Int { - Set(nodes.values.filter { $0.state == .ready }.compactMap(\.host?.hostId)).count + Set(nodes.compactMap { profileID, status in + connections[profileID] != nil && status.state == .ready ? status.host?.hostId : nil + }).count } var expectedCount: Int { let discovered = Set(nodes.values.compactMap(\.host?.hostId)).count @@ -193,7 +240,11 @@ final class DashboardStore { } func connectionState(for hostID: String) -> NodeConnectionState { - nodes.values.first(where: { $0.host?.hostId == hostID })?.state ?? .offline + nodes.compactMap { profileID, status in + status.host?.hostId == hostID && connections[profileID] != nil ? status.state : nil + }.max { + connectionPriority($0) < connectionPriority($1) + } ?? .offline } func agent(for reference: AgentReference) -> RoutedAgent? { @@ -700,12 +751,20 @@ final class DashboardStore { } private func deliver(_ command: RelayCommand, to hostID: String) async throws -> RelayDelivery { - guard let pair = nodes.first(where: { - $0.value.host?.hostId == hostID - && ($0.value.state == .ready || $0.value.state == .degraded) - }), - let connection = connections[pair.key] - else { + let candidates = nodes.compactMap { profileID, status -> (NodeStatus, any RelayNodeConnecting)? in + guard status.host?.hostId == hostID, + status.state == .ready || status.state == .degraded, + let connection = connections[profileID] + else { return nil } + return (status, connection) + }.sorted { left, right in + if connectionPriority(left.0.state) != connectionPriority(right.0.state) { + return connectionPriority(left.0.state) > connectionPriority(right.0.state) + } + return (left.0.lastSnapshotReceivedAt ?? .distantPast) + > (right.0.lastSnapshotReceivedAt ?? .distantPast) + } + guard let connection = candidates.first?.1 else { throw CommandTransactionError.hostOffline } return try await connection.send(command) @@ -1172,7 +1231,8 @@ final class DashboardStore { hostLabel: agent.originPlatform.shortLabel, activityAt: Date(timeIntervalSince1970: agent.activityAt / 1_000), selected: agent.selected, - contextUsedPercent: agent.contextUsedPercent) + contextUsedPercent: agent.contextUsedPercent, + hostConnected: [.ready, .degraded].contains(connectionState(for: agent.host.hostId))) } let activeStatuses = Set([ "working", "thinking", "approval", "awaiting-approval", "awaiting-response", "error", diff --git a/ios/CodexDeckMobile/Views/DashboardView.swift b/ios/CodexDeckMobile/Views/DashboardView.swift index 0456d38..ad31eaf 100644 --- a/ios/CodexDeckMobile/Views/DashboardView.swift +++ b/ios/CodexDeckMobile/Views/DashboardView.swift @@ -513,6 +513,8 @@ private struct AgentCard: View { } } .buttonStyle(.plain) + .disabled(hostState == .offline || hostState == .connecting) + .opacity(hostState == .offline || hostState == .connecting ? 0.68 : 1) .accessibilityHint("Opens this task on \(agent.host.hostName)") } diff --git a/ios/CodexDeckMobile/Views/MicroDeviceView.swift b/ios/CodexDeckMobile/Views/MicroDeviceView.swift index fd0a9ef..fd6d31f 100644 --- a/ios/CodexDeckMobile/Views/MicroDeviceView.swift +++ b/ios/CodexDeckMobile/Views/MicroDeviceView.swift @@ -246,6 +246,10 @@ private struct MicroAgentKey: View { private var agent: RoutedAgent? { placement.agent } private var reference: AgentReference? { placement.reference } + private var hostState: NodeConnectionState { + agent.map { store.connectionState(for: $0.host.hostId) } ?? .offline + } + private var hostConnected: Bool { hostState == .ready || hostState == .degraded } var body: some View { ZStack { @@ -253,21 +257,27 @@ private struct MicroAgentKey: View { VStack(spacing: 2) { HStack(spacing: 3) { if let context = agent.contextUsedPercent, store.showContextRings { - ContextUsageIndicator(percent: context, status: agent.status) + ContextUsageIndicator( + percent: context, status: hostConnected ? agent.status : "offline") } else { - Circle().fill(CodexTheme.statusColor(agent.status)) + Circle().fill( + hostConnected ? CodexTheme.statusColor(agent.status) : CodexTheme.secondary) .frame(width: 5, height: 5) } Spacer(minLength: 0) Text(agent.originPlatform.shortLabel) .font(.system(size: 6.5, weight: .black)) + .foregroundStyle(hostConnected ? CodexTheme.ink : CodexTheme.red) } Spacer(minLength: 0) Text(agent.title) .font(.system(size: 7.1, weight: .semibold)) .lineLimit(2) .multilineTextAlignment(.center) - .foregroundStyle(CodexTheme.ink.opacity(agent.selected ? 0.92 : 0.68)) + .foregroundStyle( + hostConnected + ? CodexTheme.ink.opacity(agent.selected ? 0.92 : 0.68) + : CodexTheme.secondary) Spacer(minLength: 0) } } else { @@ -291,10 +301,13 @@ private struct MicroAgentKey: View { } } .scaleEffect(pressing ? 1.035 : 1) + .opacity(agent != nil && !hostConnected ? 0.62 : 1) .animation(.smooth(duration: 0.18), value: pressing) .contentShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) .onTapGesture { - guard let agent, Date().timeIntervalSince(lastLongPressAt) > 0.35 else { return } + guard let agent, hostConnected, Date().timeIntervalSince(lastLongPressAt) > 0.35 else { + return + } Task { await store.activate(agent) } } .onLongPressGesture( diff --git a/ios/CodexDeckMobileTests/MobileMergeTests.swift b/ios/CodexDeckMobileTests/MobileMergeTests.swift index daa28a6..a3bd29a 100644 --- a/ios/CodexDeckMobileTests/MobileMergeTests.swift +++ b/ios/CodexDeckMobileTests/MobileMergeTests.swift @@ -160,6 +160,52 @@ final class MobileMergeTests: XCTestCase { XCTAssertEqual(result?.snapshot.usage?.resetCreditsAvailable, 1) } + func testSnapshotReceiptNormalizationRemovesHostClockSkew() throws { + let mac = host("mac", .darwin) + let thread = "11111111-1111-4111-8111-111111111111" + let input = HostSnapshot( + host: mac, + observedAt: 1_000_000, + snapshot: MicroSnapshot( + slots: (0..<6).map { index in + AgentSlot( + id: index, + threadKey: index == 0 ? thread : nil, + title: index == 0 ? "Clock skew" : nil, + status: index == 0 ? "working" : "off", + selected: index == 0, + activityAt: index == 0 ? 990_000 : nil, + ownedByHost: index == 0 ? true : nil) + }, + activeThreadKey: thread, + activeThreadTitle: "Clock skew", + layout: MicroLayout(version: 1, slots: [:]), + agentSource: "recent", + lightingAutoOff: "never", + theme: "light", + usage: UsageSnapshot( + windows: [ + UsageWindow( + id: "weekly", kind: "weekly", usedPercent: 40, remainingPercent: 60, + windowDurationMins: 10_080, resetsAt: 1_600_000) + ], + observedAt: 1_000_000, + resetCreditsAvailable: 1, + resetCreditsApplicable: 1), + hostSessions: [ + HostSessionPresence( + threadId: thread, activityAt: 970_000, status: "working", + completionRevision: nil) + ])) + + let normalized = input.normalizedToReceiptTime(1_030_000) + XCTAssertEqual(normalized.observedAt, 1_030_000) + XCTAssertEqual(normalized.snapshot.slots[0].activityAt, 1_020_000) + XCTAssertEqual(normalized.snapshot.hostSessions?[0].activityAt, 1_000_000) + XCTAssertEqual(normalized.snapshot.usage?.observedAt, 1_030_000) + XCTAssertEqual(normalized.snapshot.usage?.windows[0].resetsAt, 1_630_000) + } + func testContextUsageFollowsTheBackingHostAcrossMirrors() { let windows = host("win", .win32) let mac = host("mac", .darwin) @@ -774,6 +820,108 @@ final class MobileMergeTests: XCTestCase { XCTAssertTrue(store.nodes[second.id]?.detail?.contains("Duplicate connection") == true) } + @MainActor + func testDuplicateProfileRoutesCommandsThroughTheHealthyConnection() async throws { + let suiteName = "CodexDeckMobileTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let first = NodeProfile( + id: UUID(), name: "Keeper", url: URL(string: "wss://keeper.example.ts.net")!) + let second = NodeProfile( + id: UUID(), name: "Duplicate", url: URL(string: "wss://duplicate.example.ts.net")!) + defaults.set(try JSONEncoder().encode([first, second]), forKey: "node-profiles") + for profile in [first, second] { + try KeychainStore.set("t".repeated(32), for: profile.tokenKey) + } + defer { [first, second].forEach { KeychainStore.remove($0.tokenKey) } } + var connections: [UUID: MockRelayConnection] = [:] + let store = DashboardStore(defaults: defaults) { profile, _, update in + let connection = MockRelayConnection(profileID: profile.id, update: update) + connections[profile.id] = connection + return connection + } + await store.start() + let authenticated = host("shared-host", .darwin) + connections[second.id]?.publishStatus(NodeStatus(state: .ready, host: authenticated)) + connections[first.id]?.publishStatus(NodeStatus(state: .ready, host: authenticated)) + + XCTAssertEqual(store.connectionState(for: authenticated.hostId), .ready) + await store.pressEncoder() + + XCTAssertEqual(connections[first.id]?.commands.count, 2) + XCTAssertTrue(connections[second.id]?.commands.isEmpty == true) + XCTAssertEqual(store.commandReceipt?.stage, .stateConfirmed) + } + + @MainActor + func testOfflineSingleHostSnapshotIsVisibleButNotReportedConnected() throws { + let suiteName = "CodexDeckMobileTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let profile = NodeProfile( + id: UUID(), name: "Offline Mac", url: URL(string: "wss://mac.example.ts.net")!) + let mac = host("offline-mac", .darwin) + defaults.set(try JSONEncoder().encode([profile]), forKey: "node-profiles") + defaults.set( + try JSONEncoder().encode( + snapshot( + host: mac, + slot: slot( + thread: "11111111-1111-4111-8111-111111111111", title: "Last known task", + status: "working"), + sessions: [])), + forKey: "snapshot-\(profile.id.uuidString)") + + let store = DashboardStore(defaults: defaults) + XCTAssertEqual(store.agents.first?.title, "Last known task") + XCTAssertEqual(store.connectionState(for: mac.hostId), .offline) + XCTAssertEqual(store.connectedCount, 0) + } + + @MainActor + func testMixedConnectionKeepsUniqueOfflineTasksWithoutOverridingLiveTasks() async throws { + let suiteName = "CodexDeckMobileTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let liveProfile = NodeProfile( + id: UUID(), name: "Windows", url: URL(string: "wss://windows.example.ts.net")!) + let offlineProfile = NodeProfile( + id: UUID(), name: "Mac", url: URL(string: "wss://mac.example.ts.net")!) + defaults.set( + try JSONEncoder().encode([liveProfile, offlineProfile]), forKey: "node-profiles") + for profile in [liveProfile, offlineProfile] { + try KeychainStore.set("t".repeated(32), for: profile.tokenKey) + } + defer { [liveProfile, offlineProfile].forEach { KeychainStore.remove($0.tokenKey) } } + let offlineMac = host("offline-mac", .darwin) + defaults.set( + try JSONEncoder().encode( + snapshot( + host: offlineMac, + slot: slot( + thread: "22222222-2222-4222-8222-222222222222", title: "Offline Mac task", + status: "working"), + sessions: [])), + forKey: "snapshot-\(offlineProfile.id.uuidString)") + var connections: [UUID: MockRelayConnection] = [:] + let store = DashboardStore(defaults: defaults) { profile, _, update in + let connection = MockRelayConnection(profileID: profile.id, update: update) + connections[profile.id] = connection + return connection + } + await store.start() + connections[liveProfile.id]?.publish( + snapshot( + host: host("live-win", .win32), + slot: slot( + thread: "33333333-3333-4333-8333-333333333333", title: "Live Windows task", + status: "idle"), + sessions: [])) + + XCTAssertEqual(Set(store.agents.map(\.title)), Set(["Live Windows task", "Offline Mac task"])) + XCTAssertEqual(store.connectionState(for: offlineMac.hostId), .offline) + } + @MainActor func testComputerReplacementRequiresAnExplicitTargetBeforeAcceptingANewHostID() throws { let suiteName = "CodexDeckMobileTests.\(UUID().uuidString)" diff --git a/ios/CodexDeckShared/CodexWidgetState.swift b/ios/CodexDeckShared/CodexWidgetState.swift index d5cbfc9..fa6a134 100644 --- a/ios/CodexDeckShared/CodexWidgetState.swift +++ b/ios/CodexDeckShared/CodexWidgetState.swift @@ -10,10 +10,12 @@ struct CodexWidgetAgent: Codable, Equatable, Identifiable, Sendable { let activityAt: Date let selected: Bool let contextUsedPercent: Double? + let hostConnected: Bool? init( id: String, title: String, status: String, hostName: String, hostLabel: String, - activityAt: Date, selected: Bool, contextUsedPercent: Double? = nil + activityAt: Date, selected: Bool, contextUsedPercent: Double? = nil, + hostConnected: Bool = true ) { self.id = id self.title = title @@ -23,7 +25,10 @@ struct CodexWidgetAgent: Codable, Equatable, Identifiable, Sendable { self.activityAt = activityAt self.selected = selected self.contextUsedPercent = contextUsedPercent + self.hostConnected = hostConnected } + + var isHostConnected: Bool { hostConnected ?? true } } struct CodexWidgetUsage: Codable, Equatable, Sendable { @@ -135,6 +140,7 @@ enum CodexWidgetStateStore { return [ agent.id, agent.title, agent.status, agent.hostName, agent.hostLabel, agent.selected ? "1" : "0", agent.contextUsedPercent.map { String($0) } ?? "", + agent.isHostConnected ? "1" : "0", ] } diff --git a/ios/CodexDeckWidgets/CodexDeckWidgets.swift b/ios/CodexDeckWidgets/CodexDeckWidgets.swift index 53665df..311f6fc 100644 --- a/ios/CodexDeckWidgets/CodexDeckWidgets.swift +++ b/ios/CodexDeckWidgets/CodexDeckWidgets.swift @@ -368,12 +368,15 @@ private struct CurrentAgentWidgetView: View { } if family == .systemMedium { HStack { - Label(agent.status.replacingOccurrences(of: "-", with: " "), systemImage: "waveform.path") + Label( + agent.isHostConnected + ? agent.status.replacingOccurrences(of: "-", with: " ") : "offline", + systemImage: agent.isHostConnected ? "waveform.path" : "wifi.slash") Spacer() if agent.selected { Label("Selected", systemImage: "viewfinder") } } .font(.caption.weight(.semibold)) - .foregroundStyle(CodexWidgetPalette.status(agent.status)) + .foregroundStyle(CodexWidgetPalette.status(displayStatus(agent))) } } else { EmptyWidgetState(message: "No active agent yet") @@ -428,18 +431,24 @@ private struct AgentTile: View { .lineLimit(compact ? 3 : 2) .privacySensitive() Spacer(minLength: 0) - Text(agent.activityAt, style: .relative) - .font(.system(size: 8, weight: .medium)) - .foregroundStyle(CodexWidgetPalette.secondary) + if agent.isHostConnected { + Text(agent.activityAt, style: .relative) + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(CodexWidgetPalette.secondary) + } else { + Label("Offline", systemImage: "wifi.slash") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(CodexWidgetPalette.secondary) + } } .padding(compact ? 9 : 11) .frame(maxWidth: .infinity, minHeight: compact ? 82 : 94, alignment: .topLeading) .background( - CodexWidgetPalette.status(agent.status).opacity(agent.selected ? 0.18 : 0.08), + CodexWidgetPalette.status(displayStatus(agent)).opacity(agent.selected ? 0.18 : 0.08), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(CodexWidgetPalette.status(agent.status).opacity(agent.selected ? 0.7 : 0.18)) + .stroke(CodexWidgetPalette.status(displayStatus(agent)).opacity(agent.selected ? 0.7 : 0.18)) } } } @@ -450,7 +459,7 @@ private struct WidgetAgentStatusOrb: View { var body: some View { ZStack { - Circle().fill(CodexWidgetPalette.status(agent.status).opacity(0.13)) + Circle().fill(CodexWidgetPalette.status(displayStatus(agent)).opacity(0.13)) Circle().stroke(CodexWidgetPalette.panel, lineWidth: 3) if let context = agent.contextUsedPercent { Circle() @@ -458,9 +467,9 @@ private struct WidgetAgentStatusOrb: View { .stroke(contextColor(context), style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) } - Image(systemName: statusSymbol(agent.status)) + Image(systemName: agent.isHostConnected ? statusSymbol(agent.status) : "wifi.slash") .font(.system(size: size * 0.34, weight: .bold)) - .foregroundStyle(CodexWidgetPalette.status(agent.status)) + .foregroundStyle(CodexWidgetPalette.status(displayStatus(agent))) } .frame(width: size, height: size) .widgetAccentable() @@ -479,7 +488,7 @@ private struct WidgetContextIndicator: View { .stroke(contextColor(context), style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) .rotationEffect(.degrees(-90)) } - Circle().fill(CodexWidgetPalette.status(agent.status)).frame(width: 3, height: 3) + Circle().fill(CodexWidgetPalette.status(displayStatus(agent))).frame(width: 3, height: 3) } .frame(width: 10, height: 10) .widgetAccentable() @@ -591,6 +600,10 @@ private func statusSymbol(_ status: String) -> String { return "circle.fill" } +private func displayStatus(_ agent: CodexWidgetAgent) -> String { + agent.isHostConnected ? agent.status : "offline" +} + struct CodexCapacityWidget: Widget { let kind = "com.simeo.codexdeck.capacity" diff --git a/package-lock.json b/package-lock.json index 5362bf2..e01f9f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-stream-deck", - "version": "0.7.0-hotfix.1", + "version": "0.7.0-hotfix.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-stream-deck", - "version": "0.7.0-hotfix.1", + "version": "0.7.0-hotfix.2", "license": "MIT", "dependencies": { "@elgato/streamdeck": "2.1.0", diff --git a/package.json b/package.json index d4c6c43..2c1b4d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-stream-deck", - "version": "0.7.0-hotfix.1", + "version": "0.7.0-hotfix.2", "private": false, "type": "module", "description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay", diff --git a/src/codex-relay-client.ts b/src/codex-relay-client.ts index 7bdd06f..fe00269 100644 --- a/src/codex-relay-client.ts +++ b/src/codex-relay-client.ts @@ -5,7 +5,7 @@ import WebSocket from "ws"; import { codexDeckStateRoot } from "./codex-deck-paths.js"; import { isAllowedRelayHost } from "./relay-network.js"; import { - RELAY_PROTOCOL_VERSION, parseRelayServerMessage, + RELAY_PROTOCOL_VERSION, normalizeHostSnapshotAtReceipt, parseRelayServerMessage, type HostSnapshot, type RelayCommand, type RelayResultMessage } from "./relay-protocol.js"; import type { CodexHost, HostHealth } from "./types.js"; @@ -102,10 +102,14 @@ export class CodexRelayClient { this.health = { state: "degraded", reason: "awaiting-snapshot", changedAt: Date.now() }; this.log(`Remote Codex host connected: ${message.host.hostName} (${message.host.platform}).`); } else if (message.type === "snapshot") { + const receivedAt = Date.now(); this.host = message.host; - this.snapshot = { host: message.host, snapshot: message.snapshot, observedAt: message.observedAt }; - this.lastSnapshotReceivedAt = Date.now(); - this.health = { state: "ready", changedAt: Date.now() }; + this.snapshot = normalizeHostSnapshotAtReceipt( + { host: message.host, snapshot: message.snapshot, observedAt: message.observedAt }, + receivedAt + ); + this.lastSnapshotReceivedAt = receivedAt; + this.health = { state: "ready", changedAt: receivedAt }; this.onSnapshot(this.snapshot); } else if (message.type === "health") { this.host = message.host; diff --git a/src/control-target.ts b/src/control-target.ts index 6ab6f42..160e293 100644 --- a/src/control-target.ts +++ b/src/control-target.ts @@ -6,6 +6,14 @@ import type { CodexHost } from "./types.js"; export type HostPlatform = CodexHost["platform"]; const CONTROL_TARGET_PATH = join(codexDeckStateRoot(), "control-target.json"); +export function resolveStartupControlTarget( + persisted: HostPlatform, + local: HostPlatform, + relayConfigured: boolean +): HostPlatform { + return relayConfigured ? persisted : local; +} + export function isRemoteControlRequest( targetPlatform: HostPlatform, localPlatform: HostPlatform, diff --git a/src/controller.ts b/src/controller.ts index 8ab6280..ae08aac 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -2,7 +2,10 @@ import streamDeck, { type KeyAction } from "@elgato/streamdeck"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { codexDeckStateRoot } from "./codex-deck-paths.js"; -import { isRemoteControlRequest, readControlTarget, writeControlTarget, type HostPlatform as ControlTarget } from "./control-target.js"; +import { + isRemoteControlRequest, readControlTarget, resolveStartupControlTarget, writeControlTarget, + type HostPlatform as ControlTarget +} from "./control-target.js"; import { CodexRelayClient, readRelayClientConfig } from "./codex-relay-client.js"; import { CodexRelayServer, readRelayServerConfig } from "./codex-relay-server.js"; import { CodexMicroRendererBridge } from "./codex-micro-renderer-bridge.js"; @@ -82,9 +85,12 @@ export class DeckController { streamDeck.logger.warn(`Context-ring settings were unavailable; using enabled by default: ${String(error)}`); } this.localHost = await getOrCreateHostIdentity(); - this.targetPlatform = await readControlTarget(undefined, this.localHost.platform); - if (this.targetPlatform === this.localHost.platform) this.targetHostId = this.localHost.hostId; + const persistedTarget = await readControlTarget(undefined, this.localHost.platform); const relayConfig = await readRelayClientConfig(); + this.targetPlatform = resolveStartupControlTarget( + persistedTarget, this.localHost.platform, relayConfig != null); + if (this.targetPlatform !== persistedTarget) await writeControlTarget(this.targetPlatform); + if (this.targetPlatform === this.localHost.platform) this.targetHostId = this.localHost.hostId; if (relayConfig) { this.relayClient = new CodexRelayClient( relayConfig, diff --git a/src/relay-protocol.ts b/src/relay-protocol.ts index 1e35132..6bab6a1 100644 --- a/src/relay-protocol.ts +++ b/src/relay-protocol.ts @@ -46,6 +46,46 @@ export type RelayServerMessage = RelayReadyMessage | RelaySnapshotMessage | Rela export type HostSnapshot = { host: CodexHost; snapshot: MicroSnapshot; observedAt: number }; +export function normalizeHostSnapshotAtReceipt( + input: HostSnapshot, + receivedAt = Date.now() +): HostSnapshot { + if (!Number.isFinite(receivedAt) || receivedAt <= 0 || !Number.isFinite(input.observedAt) || input.observedAt <= 0) { + return input; + } + const offset = receivedAt - input.observedAt; + const shift = (value: number): number => { + if (!Number.isFinite(value) || value <= 0) return value; + return Math.max(1, value + offset); + }; + const usage = input.snapshot.usage + ? { + ...input.snapshot.usage, + observedAt: shift(input.snapshot.usage.observedAt)!, + windows: input.snapshot.usage.windows.map((window) => ({ + ...window, + resetsAt: window.resetsAt == null ? null : shift(window.resetsAt) + })) + } + : undefined; + return { + host: input.host, + observedAt: receivedAt, + snapshot: { + ...input.snapshot, + slots: input.snapshot.slots.map((slot) => ({ + ...slot, + activityAt: slot.activityAt == null ? undefined : shift(slot.activityAt) + })), + hostSessions: input.snapshot.hostSessions?.map((session) => ({ + ...session, + activityAt: shift(session.activityAt)! + })), + usage + } + }; +} + type ActivityRecord = { activityAt: number; signature: string; lastSeenAt: number }; type SessionOwner = { input: HostSnapshot; session: HostSessionPresence }; type TemporaryAliasRecord = { identity: string; lastSeenAt: number }; diff --git a/static/manifest.json b/static/manifest.json index aafd638..c008fc7 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json", "Name": "Codex Deck", - "Version": "0.7.0.1", + "Version": "0.7.0.2", "Author": "Dazer", "Description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay.", "UUID": "com.simeo.codex-deck", diff --git a/test/control-target.test.ts b/test/control-target.test.ts index e5ee47f..156df62 100644 --- a/test/control-target.test.ts +++ b/test/control-target.test.ts @@ -3,7 +3,9 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { isRemoteControlRequest, readControlTarget } from "../src/control-target.js"; +import { + isRemoteControlRequest, readControlTarget, resolveStartupControlTarget +} from "../src/control-target.js"; test("control targeting treats each platform as local on its own host", () => { assert.equal(isRemoteControlRequest("win32", "win32"), false); @@ -26,3 +28,13 @@ test("invalid persisted targets fall back to the local platform", async () => { assert.equal(await readControlTarget(join(root, "missing.json"), "win32"), "win32"); } finally { await rm(root, { recursive: true, force: true }); } }); + +test("single-host startup resets a stale opposite-host target", () => { + assert.equal(resolveStartupControlTarget("win32", "darwin", false), "darwin"); + assert.equal(resolveStartupControlTarget("darwin", "win32", false), "win32"); +}); + +test("configured relay preserves an explicit remote target while offline", () => { + assert.equal(resolveStartupControlTarget("win32", "darwin", true), "win32"); + assert.equal(resolveStartupControlTarget("darwin", "win32", true), "darwin"); +}); diff --git a/test/relay.test.ts b/test/relay.test.ts index 708d760..f7bb6bc 100644 --- a/test/relay.test.ts +++ b/test/relay.test.ts @@ -13,7 +13,10 @@ import { CodexRelayServer, readRelayServerConfig, relayDiscoveryTxt, relaySnapshotFailureShouldDegrade, validateRelayServerConfig } from "../src/codex-relay-server.js"; -import { HostActivityIndex, RELAY_PROTOCOL_VERSION, parseRelayCommand, type HostSnapshot } from "../src/relay-protocol.js"; +import { + HostActivityIndex, RELAY_PROTOCOL_VERSION, normalizeHostSnapshotAtReceipt, + parseRelayCommand, type HostSnapshot +} from "../src/relay-protocol.js"; import type { CodexHost, MicroSnapshot } from "../src/types.js"; const host: CodexHost = { hostId: "56fd97ad-7073-42cc-85ce-befa17546d7c", hostName: "Test Mac", platform: "darwin" }; @@ -174,6 +177,59 @@ test("relay health becomes degraded from local receipt age without trusting remo assert.equal(resolveRelayHealth(offline, true, 1_000, 99_000), offline); }); +test("remote snapshots are normalized to the receiver clock", () => { + const remote = structuredClone(snapshot); + remote.hostSessions = [{ + threadId: remote.slots[0]!.threadKey!, activityAt: 970_000, + status: "working", completionRevision: undefined + }]; + remote.usage = { + windows: [{ + id: "weekly", kind: "weekly", usedPercent: 40, remainingPercent: 60, + windowDurationMins: 10_080, resetsAt: 1_600_000 + }], + observedAt: 1_000_000, + resetCreditsAvailable: 1, + resetCreditsApplicable: 1 + }; + remote.slots[0]!.activityAt = 990_000; + + const normalized = normalizeHostSnapshotAtReceipt( + { host, snapshot: remote, observedAt: 1_000_000 }, 1_030_000); + assert.equal(normalized.observedAt, 1_030_000); + assert.equal(normalized.snapshot.slots[0]!.activityAt, 1_020_000); + assert.equal(normalized.snapshot.hostSessions![0]!.activityAt, 1_000_000); + assert.equal(normalized.snapshot.usage!.observedAt, 1_030_000); + assert.equal(normalized.snapshot.usage!.windows[0]!.resetsAt, 1_630_000); +}); + +test("clock skew cannot hide a remote owner status or selection", () => { + const windows: CodexHost = { + hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" + }; + const threadKey = snapshot.slots[0]!.threadKey!; + const macMirror = structuredClone(snapshot); + const windowsOwner = structuredClone(snapshot); + macMirror.slots[0]!.status = "idle"; + macMirror.slots[0]!.selected = false; + macMirror.slots[0]!.ownedByHost = false; + windowsOwner.slots[0]!.status = "working"; + windowsOwner.slots[0]!.selected = true; + windowsOwner.slots[0]!.ownedByHost = true; + windowsOwner.hostSessions = [{ + threadId: threadKey, activityAt: 995_000, status: "working", completionRevision: undefined + }]; + const normalizedRemote = normalizeHostSnapshotAtReceipt( + { host: windows, snapshot: windowsOwner, observedAt: 1_000_000 }, 1_030_000); + const merged = new HostActivityIndex().merge([ + { host, snapshot: macMirror, observedAt: 1_030_000 }, normalizedRemote + ]); + const task = merged.find((slot) => slot.threadKey === threadKey)!; + assert.equal(task.status, "working"); + assert.equal(task.selected, true); + assert.equal(task.host.hostId, windows.hostId); +}); + test("relay suppresses one transient renderer failure after a healthy snapshot", () => { assert.equal(relaySnapshotFailureShouldDegrade(false, 1), true, "initial failure has no safe snapshot"); assert.equal(relaySnapshotFailureShouldDegrade(true, 1), false, "one transient failure keeps last-known state"); diff --git a/test/release-docs.test.ts b/test/release-docs.test.ts index 2fbdc1c..da85116 100644 --- a/test/release-docs.test.ts +++ b/test/release-docs.test.ts @@ -16,7 +16,7 @@ test("iPhone source-install docs state the current Mac and distribution boundary assert.match(prose, /control only\s+(?:a\s+)?Windows/i); assert.match(prose, /App Store/); } - assert.match(install, /git clone --branch v0\.7\.0\.1 --depth 1/); + assert.match(install, /git clone --branch v0\.7\.0\.2 --depth 1/); }); test("release docs preserve inspiration credit and independent implementation wording", async () => {