Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0d2b732
Fix main build blockers before release verification
azooz2003-bit Oct 1, 2026
0d7239b
Document intentional settings namespace
azooz2003-bit Oct 1, 2026
56837e5
Include the hook-doc helper in schema guard fixture
azooz2003-bit Oct 1, 2026
ef7f5f8
Complete the agent hook docs guard fixture
azooz2003-bit Oct 1, 2026
6e9a537
fix CI test fixtures and canonical resource lookup
azooz2003-bit Oct 1, 2026
2bac0fa
keep rerun workflow source alias unchanged
azooz2003-bit Oct 1, 2026
db6e391
exclude template manifest from sidebar validation
azooz2003-bit Oct 1, 2026
337f0b5
repair session-bound notification fixtures
azooz2003-bit Oct 1, 2026
8f5550c
fix: suppress notifications for aborted Codex monitor replays
azooz2003-bit Oct 1, 2026
8374a1b
fix: handle aborted transcripts in hook admission
azooz2003-bit Oct 1, 2026
0510b7c
Fix package test regressions
azooz2003-bit Oct 1, 2026
806cb15
Restore updater badge renderer
azooz2003-bit Oct 1, 2026
0beabb7
Merge green main into release gate branch
azooz2003-bit Oct 1, 2026
36651d9
Merge main (337861cfd05e) into fix/acceptance-v2-gate-20261001
azooz2003-bit Oct 1, 2026
f58f193
ci: make v2 acceptance gate enforce real workload
azooz2003-bit Oct 1, 2026
b4e2672
test: close acceptance branch review findings
azooz2003-bit Oct 1, 2026
6459994
docs: document strict Codex acceptance prerequisite
azooz2003-bit Oct 1, 2026
7428e6f
ci: measure relay behavior under network delay
azooz2003-bit Oct 1, 2026
4beac05
ci: record canonical worker identity
azooz2003-bit Oct 1, 2026
097bf9a
docs: list current cloud VM socket methods
azooz2003-bit Oct 1, 2026
6c131b2
test: align gate timeout guard with relay rollover
azooz2003-bit Oct 1, 2026
c4bf7b1
test: keep app machine payload test target-safe
azooz2003-bit Oct 1, 2026
fee0ee0
test: expect inbox mark-read to cover all results
azooz2003-bit Oct 1, 2026
9737bc7
ci: make relay impairment cleanup and evidence fail safe
azooz2003-bit Oct 1, 2026
f439359
Merge remote-tracking branch 'origin/main' into fix/acceptance-v2-gat…
azooz2003-bit Oct 1, 2026
259d531
fix: restore upload path helper after main merge
azooz2003-bit Oct 1, 2026
6910617
fix: compile agent usage owner selection
azooz2003-bit Oct 1, 2026
ef2c886
fix: use app-local shell quoting helper
azooz2003-bit Oct 1, 2026
06b298a
fix: resolve sidebar test conflict markers
azooz2003-bit Oct 1, 2026
a5ce445
test: stabilize package tests after main merge
azooz2003-bit Oct 1, 2026
fa9b5fd
Merge remote-tracking branch 'origin/main' into fix/acceptance-v2-gat…
azooz2003-bit Oct 1, 2026
278d636
fix: restore sidebar preview resources for compile
azooz2003-bit Oct 1, 2026
58c1700
test: account for paste cleanup during relay teardown
azooz2003-bit Oct 2, 2026
9de35d6
test: avoid predicate macro expansion in relay teardown
azooz2003-bit Oct 2, 2026
1b61c17
Merge origin/main into acceptance gate
azooz2003-bit Oct 2, 2026
2dbbcd5
test: keep sidebar template example in sync
azooz2003-bit Oct 2, 2026
6f7170f
Merge latest main into acceptance gate
azooz2003-bit Oct 2, 2026
1bac05f
Merge latest main into acceptance gate
azooz2003-bit Oct 2, 2026
86a6aea
test: match conditional paste cleanup lifecycle
azooz2003-bit Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions .github/workflows/iroh-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -365,10 +365,10 @@ jobs:
chmod 600 "$production_env"

- name: Run Iroh gate
# Every non-soak phase is capped at 25 minutes. Stress soaks retain
# their one-hour workload window while the gate script bounds each
# setup, build, and launch phase separately.
timeout-minutes: ${{ inputs.soak_profile == 'stress' && 75 || 25 }}
# Relay-only stress is one hour of use plus the 30-minute credential
# rollover observation and bounded cleanup. The script's report
# timeout is 5,850 seconds, so this step must be longer than that.
timeout-minutes: ${{ inputs.soak_profile == 'stress' && 125 || 25 }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The 125-minute step limit can cancel relay-only stress while the gate is still within its own phase and report deadlines: both builds and prewarm run before the 5,850-second report wait. Include that setup/build time in the step and job timeout, or lower the phase limits so the full sequence fits.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/iroh-release-gate.yml, line 371:

<comment>The 125-minute step limit can cancel relay-only stress while the gate is still within its own phase and report deadlines: both builds and prewarm run before the 5,850-second report wait. Include that setup/build time in the step and job timeout, or lower the phase limits so the full sequence fits.</comment>

<file context>
@@ -365,10 +365,10 @@ jobs:
+        # Relay-only stress is one hour of use plus the 30-minute credential
+        # rollover observation and bounded cleanup. The script's report
+        # timeout is 5,850 seconds, so this step must be longer than that.
+        timeout-minutes: ${{ inputs.soak_profile == 'stress' && 125 || 25 }}
         env:
           # The hosted runner does not have cmuxterm-hq's tagged-backend
</file context>

env:
# The hosted runner does not have cmuxterm-hq's tagged-backend
# helper. The explicit Worker origin remains the app's API and v2
Expand All @@ -379,6 +379,9 @@ jobs:
# has no cmux-tui artifact yet, use the newest older artifact with
# identical client inputs instead of failing before the app starts.
CMUX_TUI_CLIENT_MAX_FALLBACK: 5
CMUX_CODEX_MODEL: gpt-5.3-codex-spark
CMUX_CODEX_DURATION_SECONDS: "3600"
CMUX_CODEX_STRICT_MODEL: "1"
run: |
set -euo pipefail
case "${{ matrix.mode }}" in
Expand Down Expand Up @@ -408,11 +411,45 @@ jobs:
if [[ "${{ matrix.mode }}" == automatic || "${{ matrix.mode }}" == relay-only ]]; then
GATE_ARGS+=(--soak-profile "${{ inputs.soak_profile }}")
fi
if [[ "${{ inputs.environment }}" == production && "${{ matrix.mode }}" == relay-only && "${{ inputs.soak_profile }}" == stress ]]; then
if [[ "${{ inputs.soak_profile }}" == stress && ( "${{ matrix.mode }}" == automatic || "${{ matrix.mode }}" == relay-only ) ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new run-script condition interpolates ${{ inputs.soak_profile }} and ${{ matrix.mode }} directly into the shell instead of binding them to step env. Bind these in the step's env: block and read $SOAK_PROFILE/$MODE in the script; the surrounding shell lines for this step (and the new condition) otherwise stay tied to workflow-expression rendering and lose the file's own env-binding pattern.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/iroh-release-gate.yml, line 414:

<comment>The new run-script condition interpolates `${{ inputs.soak_profile }}` and `${{ matrix.mode }}` directly into the shell instead of binding them to step env. Bind these in the step's `env:` block and read `$SOAK_PROFILE`/`$MODE` in the script; the surrounding shell lines for this step (and the new condition) otherwise stay tied to workflow-expression rendering and lose the file's own env-binding pattern.</comment>

<file context>
@@ -408,7 +411,7 @@ jobs:
             GATE_ARGS+=(--soak-profile "${{ inputs.soak_profile }}")
           fi
-          if [[ "${{ inputs.environment }}" == production && "${{ matrix.mode }}" == relay-only && "${{ inputs.soak_profile }}" == stress ]]; then
+          if [[ "${{ inputs.soak_profile }}" == stress && ( "${{ matrix.mode }}" == automatic || "${{ matrix.mode }}" == relay-only ) ]]; then
             GATE_ARGS+=(--real-usage)
           fi
</file context>

GATE_ARGS+=(--real-usage)
fi
./scripts/run-iroh-release-gate.sh "${GATE_ARGS[@]}"

- name: Verify canonical v2 Worker identity
if: ${{ always() }}
env:
EXPECTED_ENVIRONMENT: ${{ inputs.environment }}
EXPECTED_V2_URL: ${{ inputs.environment == 'production' && 'https://cmux-v2.debussy.workers.dev' || inputs.v2_base_url }}
HEALTH_OUTPUT: ${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-worker-health.json
run: |
set -euo pipefail
curl --fail --silent --show-error --retry 3 --retry-connrefused \
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
--connect-timeout 10 --max-time 30 \
"$EXPECTED_V2_URL/v2/health" > "$HEALTH_OUTPUT"
EXPECTED_ENVIRONMENT="$EXPECTED_ENVIRONMENT" EXPECTED_V2_URL="$EXPECTED_V2_URL" HEALTH_OUTPUT="$HEALTH_OUTPUT" \
/usr/bin/python3 - <<'PY'
import json
import os
from urllib.parse import urlparse

with open(os.environ["HEALTH_OUTPUT"], encoding="utf-8") as handle:
health = json.load(handle)
expected_environment = os.environ["EXPECTED_ENVIRONMENT"]
expected_url = os.environ["EXPECTED_V2_URL"]
if health.get("schemaId") != "health.v1":
raise SystemExit("Worker health schema is not health.v1")
if health.get("environment") != expected_environment:
raise SystemExit("Worker health environment does not match the gate")
if not isinstance(health.get("sourceRevision"), str) or not health["sourceRevision"] or health["sourceRevision"] == "unknown":
raise SystemExit("Worker health did not identify its source revision")
if health.get("storage", {}).get("maxSchemaVersion", 0) < 7:
raise SystemExit("Worker storage schema is older than v2")
if urlparse(expected_url).hostname in {"cmux-iroh-v2.debussy.workers.dev", "cmux-iroh-v2-staging.debussy.workers.dev"}:
raise SystemExit("release gate used a compatibility alias instead of the canonical Worker")
print(json.dumps({"url": expected_url, "health": health}, sort_keys=True))
PY

- name: Upload redacted verdict
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand All @@ -426,6 +463,9 @@ jobs:
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-ios-*.jsonl
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-ios-*.log
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-mac-failure.cmuxdiag
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-latency.state*
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-latency.json
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-worker-health.json
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-real-usage/**
if-no-files-found: warn
retention-days: 7
Expand Down
2 changes: 1 addition & 1 deletion CLI/CMUXCLI+AgentHookAdmission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ extension CMUXCLI {
)
}
changed = true
case .unavailable, .pending, .healthy:
case .unavailable, .pending, .aborted, .healthy:
break
}
}
Expand Down
1 change: 1 addition & 0 deletions CLI/CodexTranscriptFailureReadResult.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
enum CodexTranscriptFailureReadResult {
case unavailable
case pending
case aborted
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case healthy(lastAssistantMessage: String?)
case failure(CodexHookFailureCandidate)
}
5 changes: 4 additions & 1 deletion CLI/CodexTranscriptMonitorStopReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ struct CodexTranscriptMonitorStopReplay {
let payload: String
let workspaceId: String
let surfaceId: String?
let suppressNotification: Bool

init?(
sessionId: String,
turnId: String?,
transcriptPath: String?,
workspaceId: String,
surfaceId: String?,
lastAssistantMessage: String?
lastAssistantMessage: String?,
suppressNotification: Bool = false
) {
guard !sessionId.isEmpty, !workspaceId.isEmpty else { return nil }
self.workspaceId = workspaceId
self.surfaceId = surfaceId
self.suppressNotification = suppressNotification

var object: [String: Any] = [
"session_id": sessionId,
Expand Down
22 changes: 22 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30805,6 +30805,8 @@ struct CMUXCLI {
return summarizeCodexHookFailureCandidate(failure)
case .healthy:
return nil
case .aborted:
return nil
case .pending, .unavailable:
break
}
Expand All @@ -30818,6 +30820,8 @@ struct CMUXCLI {
return summarizeCodexHookFailureCandidate(failure)
case .healthy:
return nil
case .aborted:
return nil
case .pending, .unavailable:
break
}
Expand Down Expand Up @@ -30852,6 +30856,7 @@ struct CMUXCLI {
var candidateCanPublishBeforeTerminal = false
var sawAssistantMessage = false
var sawTerminalTurn = false
var sawAbortedTurn = false
var sawRelevantTurn = turnId == nil
var lastAssistantMessage: String?
for line in lines {
Expand Down Expand Up @@ -30885,6 +30890,7 @@ struct CMUXCLI {
sawRelevantTurn = true
candidate = nil
candidateCanPublishBeforeTerminal = false
sawAbortedTurn = false
case "error":
let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"])
if let turnId, let payloadTurnId {
Expand Down Expand Up @@ -30930,6 +30936,7 @@ struct CMUXCLI {
// has no final response to classify as a failure, so let the
// normal Stop replay retire its stale prompt record.
if eventType == "turn_aborted" {
sawAbortedTurn = true
continue
}
// Codex persists fatal turn failures inside task_complete.error. Standalone
Expand Down Expand Up @@ -30979,6 +30986,9 @@ struct CMUXCLI {
if candidate != nil, turnId != nil, !sawRelevantTurn {
return .pending
}
if sawAbortedTurn, candidate == nil {
return .aborted
}
if requireTerminalCompletion, !sawTerminalTurn {
return .pending
}
Expand Down Expand Up @@ -31848,6 +31858,16 @@ struct CMUXCLI {
surfaceId: surfaceId,
lastAssistantMessage: lastAssistantMessage
)
case .aborted:
return CodexTranscriptMonitorStopReplay(
sessionId: sessionId,
turnId: turnId,
transcriptPath: currentTranscriptPath,
workspaceId: workspaceId,
surfaceId: surfaceId,
lastAssistantMessage: nil,
suppressNotification: true
)
case .pending:
break
case .unavailable:
Expand Down Expand Up @@ -37641,6 +37661,7 @@ export default {
turnID: effectiveCodexStopTurnID,
workspaceID: workspaceId,
surfaceID: surfaceId,
claimNotification: monitorReplay?.suppressNotification != true,
// Tokenized Codex launches must not let a delayed Stop
// for an older turn settle the currently active turn.
// Legacy unwrapped launches retain their historical
Expand Down Expand Up @@ -37700,6 +37721,7 @@ export default {
}
}
let suppressCompletionNotification = suppressVisibleMutations
|| monitorReplay?.suppressNotification == true
|| codexHasActiveBackgroundWork
let cursorStopApprovalNotificationKeys: [String] = {
guard def.name == "cursor", !sessionId.isEmpty else { return [] }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ import Testing
ownerChecks += 1
return true
}
#expect(!affordance.update(snapshot(below: 8), isEnabled: true, contentOwnsScrolling: ownsScrolling))
#expect(!affordance.update(snapshot(below: 9), isEnabled: true, contentOwnsScrolling: ownsScrolling))
let firstChanged = affordance.update(snapshot(below: 8), isEnabled: true, contentOwnsScrolling: ownsScrolling)
let secondChanged = affordance.update(snapshot(below: 9), isEnabled: true, contentOwnsScrolling: ownsScrolling)
#expect(!firstChanged)
#expect(!secondChanged)
#expect(!affordance.isVisible)
// The owner is asked once per departure from the bottom, not per snapshot.
#expect(ownerChecks == 1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ struct RemoteRelaySlotTeardownTests {

let succeeded = await coordinator.stopAndWait(cleanupScope: .persistentSlot)

let cleanupCommand = try #require(runner.requests.last?.arguments.last)
let cleanupCommand = try command(in: runner) { $0.contains("serve --persistent-stop --slot") }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Each predicate-based lookup is followed by a #expect that re-asserts the same substring the predicate already guaranteed (e.g. contains("serve --persistent-stop --slot"), contains("64010.slot"), contains("$HOME/.cmux/bin/cmuxd-remote")). These assertions are tautological and can never fail, so they add no coverage. Drop the redundant #expect(cleanupCommand.contains(...)) lines and keep only the checks the predicate does not cover (64010.shell, !rm -rf, !relay_socket=, .cache/cmux/paste/).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteRelaySlotTeardownTests.swift, line 296:

<comment>Each predicate-based lookup is followed by a `#expect` that re-asserts the same substring the predicate already guaranteed (e.g. `contains("serve --persistent-stop --slot")`, `contains("64010.slot")`, `contains("$HOME/.cmux/bin/cmuxd-remote")`). These assertions are tautological and can never fail, so they add no coverage. Drop the redundant `#expect(cleanupCommand.contains(...))` lines and keep only the checks the predicate does not cover (`64010.shell`, `!rm -rf`, `!relay_socket=`, `.cache/cmux/paste/`).</comment>

<file context>
@@ -293,7 +293,7 @@ struct RemoteRelaySlotTeardownTests {
         let succeeded = await coordinator.stopAndWait(cleanupScope: .persistentSlot)
 
-        let cleanupCommand = try #require(runner.requests.last?.arguments.last)
+        let cleanupCommand = try command(in: runner) { $0.contains("serve --persistent-stop --slot") }
         #expect(succeeded)
         #expect(cleanupCommand.contains("serve --persistent-stop --slot"))
</file context>

#expect(succeeded)
#expect(cleanupCommand.contains("serve --persistent-stop --slot"))
#expect(cleanupCommand.contains("64010.shell"))
Expand All @@ -316,7 +316,7 @@ struct RemoteRelaySlotTeardownTests {

let succeeded = await coordinator.stopAndWait(cleanupScope: .transport)

let cleanupCommand = try #require(runner.requests.last?.arguments.last)
let cleanupCommand = try command(in: runner) { $0.contains("64010.slot") }
#expect(succeeded)
#expect(!cleanupCommand.contains("serve --persistent-stop --slot"))
#expect(!cleanupCommand.contains("rm -rf"))
Expand All @@ -335,7 +335,7 @@ struct RemoteRelaySlotTeardownTests {

let succeeded = await coordinator.stopAndWait(cleanupScope: .persistentSlot)

let cleanupCommand = try #require(runner.requests.last?.arguments.last)
let cleanupCommand = try command(in: runner) { $0.contains("serve --persistent-stop --slot") }
#expect(succeeded)
#expect(cleanupCommand.contains("$HOME/.cmux/bin/cmuxd-remote"))
#expect(cleanupCommand.contains("serve --persistent-stop --slot"))
Expand All @@ -355,9 +355,9 @@ struct RemoteRelaySlotTeardownTests {

#expect(succeeded)
#expect(runner.requests.count == 2)
let metadataCleanup = try #require(runner.requests.first?.arguments.last)
let metadataCleanup = try command(in: runner) { $0.contains("64010.slot") }
#expect(metadataCleanup.contains("64010.slot"))
let directCleanup = try #require(runner.requests.last?.arguments.last)
let directCleanup = try command(in: runner) { $0.contains("$HOME/.cmux/bin/cmuxd-remote") }
#expect(directCleanup.contains("$HOME/.cmux/bin/cmuxd-remote"))
#expect(directCleanup.contains("serve --persistent-stop --slot"))
#expect(!directCleanup.contains("relay_socket="))
Expand Down Expand Up @@ -410,6 +410,19 @@ struct RemoteRelaySlotTeardownTests {
)
}

private func command(
in runner: SpyProcessRunner,
matching predicate: (String) -> Bool
) throws -> String {
let commands = runner.requests.compactMap(\.arguments.last)
guard let command = commands.first(where: { predicate($0) }) else {
throw MissingCleanupCommand()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: MissingCleanupCommand carries no diagnostic data, so when a teardown test fails because the expected command never reached the runner, the test output shows only the type name and nothing about which commands the coordinator actually issued. Give the error the commands that were received (or make it LocalizedError) so failures like coordinatorFallsBackToPersistentSlotStopWhenRelayMetadataIsMissing are debuggable without re-running.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteRelaySlotTeardownTests.swift, line 421:

<comment>`MissingCleanupCommand` carries no diagnostic data, so when a teardown test fails because the expected command never reached the runner, the test output shows only the type name and nothing about which commands the coordinator actually issued. Give the error the commands that were received (or make it `LocalizedError`) so failures like `coordinatorFallsBackToPersistentSlotStopWhenRelayMetadataIsMissing` are debuggable without re-running.</comment>

<file context>
@@ -416,13 +416,15 @@ struct RemoteRelaySlotTeardownTests {
-        )
+        let commands = runner.requests.compactMap(\.arguments.last)
+        guard let command = commands.first(where: { predicate($0) }) else {
+            throw MissingCleanupCommand()
+        }
+        return command
</file context>

}
return command
}

private struct MissingCleanupCommand: Error {}

enum MalformedSlotCleanupScope: String, CaseIterable, Sendable {
case persistentSlot
case transport
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// agents-board: subagents grouped by STATUS, attention first. The question
// this layout answers is "what needs me right now" - needs-input sessions
// get the loudest section at the top, everything else stays quiet.
// cmux sidebar set custom agents-board
// cmux left-sidebar set custom agents-board

const STATUS_META = {
needs_input: { label: "NEEDS YOU", color: "#FF9F0A", strong: true },
Expand Down
7 changes: 6 additions & 1 deletion Packages/macOS/CmuxUpdaterUI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let package = Package(
),
],
dependencies: [
.package(path: "../CmuxAppKitSupportUI"),
.package(path: "../CmuxFoundation"),
.package(path: "../CmuxUpdater"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
Expand All @@ -23,6 +24,7 @@ let package = Package(
name: "CmuxUpdaterUI",
dependencies: [
"CmuxFoundation",
.product(name: "CmuxAppKitSupportUI", package: "CmuxAppKitSupportUI"),
"CmuxUpdater",
.product(name: "Sparkle", package: "Sparkle"),
],
Expand All @@ -34,7 +36,10 @@ let package = Package(
),
.testTarget(
name: "CmuxUpdaterUITests",
dependencies: ["CmuxUpdaterUI"],
dependencies: [
"CmuxUpdaterUI",
.product(name: "CmuxAppKitSupportUI", package: "CmuxAppKitSupportUI"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
public import SwiftUI
import CmuxAppKitSupportUI
public import CmuxUpdater
import AppKit

/// A badge view that displays the current state of an update operation (icon, progress ring,
/// or loading spinner) for the update pill.
public struct UpdateBadge: View {
private let model: UpdateStateModel
private let appearance: UpdateAppearance

private static let iconSize: CGFloat = 14
private static let iconPointSize: CGFloat = 13
private static let iconWeight: NSFont.Weight = .semibold

/// Creates a badge for `model`, using `appearance` for the loading-spinner tint.
public init(model: UpdateStateModel, appearance: UpdateAppearance) {
self.model = model
Expand All @@ -22,7 +28,7 @@ public struct UpdateBadge: View {
private var badgeContent: some View {
if model.showsDetectedBackgroundUpdate {
if let iconName = model.iconName {
Image(systemName: iconName)
badgeImage(iconName)
}
} else {
switch model.effectiveState {
Expand All @@ -31,7 +37,7 @@ public struct UpdateBadge: View {
let progress = min(1, max(0, Double(download.progress) / Double(expectedLength)))
ProgressRingView(progress: progress)
} else {
Image(systemName: "arrow.down.circle")
badgeImage("arrow.down.circle")
}

case .extracting(let extracting):
Expand All @@ -42,11 +48,39 @@ public struct UpdateBadge: View {

default:
if let iconName = model.iconName {
Image(systemName: iconName)
badgeImage(iconName)
}
}
}
}

/// Builds the AppKit-owned request used by every updater symbol.
@MainActor
static func hostedIconRequest(
systemName: String,
tintColor: NSColor
) -> CmuxResolvedIconRequest {
CmuxResolvedIconRequest(
source: .systemSymbol(name: systemName, accessibilityDescription: nil),
size: NSSize(width: iconSize, height: iconSize),
tintColor: tintColor,
symbolWeight: iconWeight,
fallbackSource: .systemSymbol(
name: systemName,
accessibilityDescription: nil
),
symbolPointSize: iconPointSize
)
}

private func badgeImage(_ systemName: String) -> some View {
CmuxResolvedIconImage(request: Self.hostedIconRequest(
systemName: systemName,
tintColor: NSColor(appearance.foregroundColor(for: model))
))
.frame(width: Self.iconSize, height: Self.iconSize)
.accessibilityHidden(true)
}
}

private struct ProgressRingView: View {
Expand Down
Loading