From 21dbd883010c66a62e4a166a7e3c266324b114f4 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:43:42 -0700 Subject: [PATCH 01/12] test ios 1.0.6 compatibility and announcement gates --- .../MobileMacCompatPolicyTests.swift | 31 +++++++++++++++++++ web/tests/mobile-mac-compat-route.test.ts | 8 +++++ web/tests/whats-new-route.test.ts | 22 +++++++++++++ 3 files changed, 61 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacCompatPolicyTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacCompatPolicyTests.swift index a36b7e1f849e..d5712b350a6d 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacCompatPolicyTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacCompatPolicyTests.swift @@ -305,12 +305,43 @@ import Testing #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.0")?.buildKinds["internal"]?.nightly?.minBuild == 3_345_650_013_202) #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.4")?.buildKinds["internal"]?.nightly?.minBuild == 3_345_650_013_202) #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.5")?.buildKinds["internal"]?.nightly?.minBuild == 3_345_650_013_202) + #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.6")?.buildKinds["beta"]?.stableMinVersion == version("0.64.25")) + #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.6")?.buildKinds["internal"]?.stableMinVersion == version("0.64.25")) + #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.6")?.buildKinds["beta"]?.nightly?.minBaseVersion == version("0.64.25")) + #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.6")?.buildKinds["beta"]?.nightly?.minBuild == 3_522_337_919_701) #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.0")?.buildKinds["beta"]?.nightly?.minBuild == 3_345_650_013_202) #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "1.0.4")?.buildKinds["beta"]?.nightly?.minBuild == 3_345_650_013_202) // Versions below the first tier stay unconstrained. #expect(MobileMacCompatPolicy.baked.tier(forIOSVersion: "0.9.9") == nil) } + @Test func nextBetaRejectsOlderMacTransportBuilds() { + #expect(MobileMacCompatPolicy.baked.violation( + iosVersion: "1.0.6", + channel: .stable, + macAppVersion: "0.64.24", + buildType: .beta + ) != nil) + #expect(MobileMacCompatPolicy.baked.violation( + iosVersion: "1.0.6", + channel: .stable, + macAppVersion: "0.64.25", + buildType: .beta + ) == nil) + #expect(MobileMacCompatPolicy.baked.violation( + iosVersion: "1.0.6", + channel: .nightly, + macAppVersion: "0.64.25-nightly.3522337919700", + buildType: .internal + ) != nil) + #expect(MobileMacCompatPolicy.baked.violation( + iosVersion: "1.0.6", + channel: .nightly, + macAppVersion: "0.64.25-nightly.3522337919701", + buildType: .internal + ) == nil) + } + @Test func bakedPolicyUsesTheHistoricalInternalProtocolFloors() { #expect(MobileMacCompatPolicy.baked.violation( iosVersion: "1.0.3", diff --git a/web/tests/mobile-mac-compat-route.test.ts b/web/tests/mobile-mac-compat-route.test.ts index 5865277b90d4..139dba388505 100644 --- a/web/tests/mobile-mac-compat-route.test.ts +++ b/web/tests/mobile-mac-compat-route.test.ts @@ -125,6 +125,14 @@ describe("mobile-mac-compat route", () => { minBaseVersion: "0.64.22", minBuild: "3345650013202", }); + const next = mobileMacCompatList.entries[3]; + expect(next.minIOSVersion).toBe("1.0.6"); + expect(next.maxIOSVersion).toBeUndefined(); + expect(next.buildKinds?.beta).toEqual({ + stableMinVersion: "0.64.25", + nightly: { minBaseVersion: "0.64.25", minBuild: "3522337919701" }, + }); + expect(next.buildKinds?.internal).toEqual(next.buildKinds?.beta); }); test("rejects conflicting legacy and build-kind minimums", () => { diff --git a/web/tests/whats-new-route.test.ts b/web/tests/whats-new-route.test.ts index 4b48adc314cc..d09e6e71e497 100644 --- a/web/tests/whats-new-route.test.ts +++ b/web/tests/whats-new-route.test.ts @@ -120,4 +120,26 @@ describe("whats-new route channel targeting", () => { test("still accepts the legacy shape with no channel fields", () => { expect(validateList(base)).toEqual(base); }); + + test("ships the 1.0.6 connection notice only to beta and internal", () => { + const notice = whatsNewList.announcements.find( + (entry) => entry.id === "ios-1.0.6-connections", + ); + expect(notice).toEqual({ + id: "ios-1.0.6-connections", + minVersion: "1.0.6", + maxVersion: "1.0.6", + title: "What's New in 1.0.6", + releaseLabel: "1.0.6 · September 2026", + channels: ["beta", "internal"], + features: expect.arrayContaining([ + expect.objectContaining({ + title: "Updated Mac connections", + }), + expect.objectContaining({ + detail: expect.stringContaining("0.64.25-nightly.3522337919701"), + }), + ]), + }); + }); }); From 5fa5fcc9e89e0b4a7b3dcc9ed1927fbe2fca44e7 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:49:56 -0700 Subject: [PATCH 02/12] prepare iOS 1.0.6 beta compatibility release --- .../MobileMacCompatPolicy.swift | 14 +++++++ .../Debug/MobileWhatsNewPreviewView.swift | 39 ++++++++++++++----- .../MobileWhatsNewChannelGateTests.swift | 34 +++++++++++++++- ios/CHANGELOG.md | 17 ++++++++ ios/Config/Shared.xcconfig | 2 +- web/data/mobile-mac-compat.ts | 20 ++++++++++ web/data/whats-new.ts | 33 +++++++++++++++- 7 files changed, 146 insertions(+), 13 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift index a4069e641930..de4affa29c83 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift @@ -37,6 +37,7 @@ public struct MobileMacCompatPolicy: Equatable, Sendable { let maxIOS = MobileMacAppVersion(parsing: "1.0.3"), let irohIOS = MobileMacAppVersion(parsing: "1.0.4"), let nextIOS = MobileMacAppVersion(parsing: "1.0.5"), + let v2IOS = MobileMacAppVersion(parsing: "1.0.6"), let legacyStableMin = MobileMacAppVersion(parsing: "0.64.17"), let irohStableMin = MobileMacAppVersion(parsing: "0.64.20"), let rebuiltStableMin = MobileMacAppVersion(parsing: "0.64.23"), @@ -84,6 +85,7 @@ public struct MobileMacCompatPolicy: Equatable, Sendable { ), Tier( minIOSVersion: nextIOS, + maxIOSVersion: nextIOS, stableMinVersion: appStoreStableMin, nightly: appStoreNightly, buildKinds: [ @@ -94,6 +96,18 @@ public struct MobileMacCompatPolicy: Equatable, Sendable { MobileBuildType.prod.token: Requirement(stableMinVersion: appStoreStableMin, nightly: appStoreNightly), ] ), + Tier( + minIOSVersion: v2IOS, + stableMinVersion: appStoreStableMin, + nightly: appStoreNightly, + buildKinds: [ + MobileBuildType.dev.token: Requirement(stableMinVersion: devMin), + MobileBuildType.beta.token: Requirement(stableMinVersion: appStoreStableMin, nightly: appStoreNightly), + MobileBuildType.internal.token: Requirement(stableMinVersion: appStoreStableMin, nightly: appStoreNightly), + MobileBuildType.demo.token: Requirement(stableMinVersion: rebuiltStableMin), + MobileBuildType.prod.token: Requirement(stableMinVersion: appStoreStableMin, nightly: appStoreNightly), + ] + ), ]) }() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift index 215f41890e99..990d685d39f1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift @@ -1,29 +1,50 @@ #if os(iOS) && DEBUG import SwiftUI import Foundation +import CmuxMobileShellModel /// Deterministic What's New host for screenshot checks. It renders the same /// native sheet view the app presents after update, but does not require a /// signed-in or paired app session. public struct MobileWhatsNewPreviewView: View { - @State private var showsSheet = true - @State private var center = MobileWhatsNewCenter( - apiBaseURL: "https://cmux.test", - buildType: .beta, - defaults: UserDefaults(suiteName: "MobileWhatsNewPreview-\(UUID().uuidString)")!, - loader: { _ in throw URLError(.notConnectedToInternet) } - ) + @State private var showsSheet: Bool + @State private var center: MobileWhatsNewCenter + private let hasRemoteFixture: Bool - public init() {} + public init() { + let environment = ProcessInfo.processInfo.environment + let payload = environment["CMUX_UITEST_WHATS_NEW_PAYLOAD"] + let defaults = UserDefaults(suiteName: "MobileWhatsNewPreview-\(UUID().uuidString)")! + if let marker = environment["CMUX_UITEST_WHATS_NEW_ACKNOWLEDGED_ENTRY_ID"] { + defaults.set(marker, forKey: MobileWhatsNewCenter.markerKey) + } + hasRemoteFixture = payload != nil + _showsSheet = State(initialValue: payload == nil) + _center = State(initialValue: MobileWhatsNewCenter( + apiBaseURL: "https://cmux.test", + appVersion: environment["CMUX_UITEST_WHATS_NEW_VERSION"], + buildType: MobileBuildType(rawValue: environment["CMUX_UITEST_WHATS_NEW_CHANNEL"] ?? "beta") ?? .beta, + defaults: defaults, + loader: { _ in + guard let payload else { throw URLError(.notConnectedToInternet) } + return Data(payload.utf8) + } + )) + } public var body: some View { NavigationStack { MobileWhatsNewListView() } .environment(center) + .task { + guard hasRemoteFixture else { return } + await center.refresh() + showsSheet = !center.unseenPages.isEmpty + } .sheet(isPresented: $showsSheet) { MobileWhatsNewSheet( - pages: MobileWhatsNewCatalog().entries, + pages: center.unseenPages, allowedWebHosts: [], dismiss: { showsSheet = false } ) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift index 0921275f76f7..bc9069e480bf 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift @@ -17,7 +17,8 @@ import Testing private func makeCenter( buildType: MobileBuildType, payload: String? = nil, - acknowledgedEntryID: String? = nil + acknowledgedEntryID: String? = nil, + appVersion: String = "1.0.5" ) -> MobileWhatsNewCenter { let suiteName = "MobileWhatsNewChannelGateTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -30,7 +31,7 @@ import Testing } return MobileWhatsNewCenter( apiBaseURL: "https://cmux.test", - appVersion: "1.0.5", + appVersion: appVersion, buildType: buildType, defaults: defaults, loader: { _ in @@ -50,6 +51,35 @@ import Testing #expect(center.unseenPages.isEmpty) } + @Test func pinpointNoticeOnlyReachesItsVersionAndChannels() async { + let payload = #""" + {"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[{ + "id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6", + "channels":["beta","internal"],"title":"What's New in 1.0.6", + "features":[{"title":"Update your Mac","detail":"Requires cmux 0.64.25."}] + }]} + """# + for channel in [MobileBuildType.beta, .internal, .dev, .prod, .demo] { + for appVersion in ["1.0.4", "1.0.5", "1.0.6", "1.0.7"] { + let center = makeCenter( + buildType: channel, + payload: payload, + acknowledgedEntryID: "connections.v2", + appVersion: appVersion + ) + await center.refresh() + let shouldShow = appVersion == "1.0.6" && (channel == .beta || channel == .internal) + #expect(center.unseenPages.map(\.id) == (shouldShow ? ["ios-1.0.6-connections"] : [])) + if shouldShow { + center.acknowledge(center.unseenPages) + await center.refresh() + #expect(center.unseenPages.isEmpty) + #expect(center.announcementPages.count == 1) + } + } + } + } + @Test func pairingUpdateAppearsAfterAnOlderPageWasAcknowledged() async { let payload = #""" { diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 44e0b5c57ac1..7027c110b3b2 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -42,6 +42,23 @@ The top entry's version MUST equal the checked-in for a different version), so bump the beta version with `ios/scripts/bump-ios-version.sh` in the SAME change that adds the top entry. +## [1.0.6] - 2026-09-23 + +### Internal + +- Require Mac 0.64.25 or NIGHTLY 0.64.25-nightly.3522337919701 for BETA and INTERNAL 1.0.6's v2 connection service and Iroh transport. +- Show a new What's New notice only to BETA and INTERNAL 1.0.6, with exact Mac requirements, pairing opt-in, and rollback instructions. +- Keep the existing compatibility requirements for older iOS versions. +- Verify updating from 1.0.5 preserves sign-in and saved computers, and exercise reconnects after backgrounding. + +### External + +- Updated the Mac connection service and Iroh transport. +- Requires cmux 0.64.25 or later, or NIGHTLY 0.64.25-nightly.3522337919701 or later. Enable iOS pairing in Settings > Mobile on each Mac. +- If you cannot update your Mac, use cmux BETA 1.0.4 (20260817224846) in TestFlight > Previous Builds while available. Choose that exact build. + +--- + ## [1.0.5] - 2026-09-15 ### Internal diff --git a/ios/Config/Shared.xcconfig b/ios/Config/Shared.xcconfig index 2aa75169ba60..ff6aeb54f4c1 100644 --- a/ios/Config/Shared.xcconfig +++ b/ios/Config/Shared.xcconfig @@ -15,7 +15,7 @@ PRODUCT_BUNDLE_IDENTIFIER = $(CMUX_APP_BUNDLE_IDENTIFIER) // the first production App Store upload starts from its own release version. // Semver X.Y.Z; CFBundleVersion (CURRENT_PROJECT_VERSION) is the monotonic build // id stamped by ios/scripts/upload-testflight.sh on upload. -CMUX_IOS_BETA_MARKETING_VERSION = 1.0.5 +CMUX_IOS_BETA_MARKETING_VERSION = 1.0.6 CMUX_IOS_APPSTORE_MARKETING_VERSION = 1.0.0 MARKETING_VERSION = $(CMUX_IOS_BETA_MARKETING_VERSION) CURRENT_PROJECT_VERSION = 1 diff --git a/web/data/mobile-mac-compat.ts b/web/data/mobile-mac-compat.ts index 38549e0f335b..dd38424df239 100644 --- a/web/data/mobile-mac-compat.ts +++ b/web/data/mobile-mac-compat.ts @@ -73,6 +73,14 @@ export interface MobileMacCompatList { entries: MobileMacCompatEntry[]; } +// v0.64.25 includes the v2 backend, pairing opt-in, and persistent Mac +// identity fixes. Run 35223379197, attempt 01, is its first published nightly. +// Share these values with the 1.0.6 notice so its instructions match the gate. +export const ios106MacRequirement = { + stableMinVersion: "0.64.25", + nightly: { minBaseVersion: "0.64.25", minBuild: "3522337919701" }, +} satisfies MobileMacCompatRequirement; + export const mobileMacCompatList: MobileMacCompatList = { downloads: { // Keep in sync with DOWNLOAD_URL in web/app/lib/download.ts (data/ does @@ -129,6 +137,7 @@ export const mobileMacCompatList: MobileMacCompatList = { }, { minIOSVersion: "1.0.5", + maxIOSVersion: "1.0.5", stableMinVersion: "0.64.25", nightly: { minBaseVersion: "0.64.25", minBuild: "3522337919701" }, buildKinds: { @@ -148,5 +157,16 @@ export const mobileMacCompatList: MobileMacCompatList = { }, }, }, + { + minIOSVersion: "1.0.6", + ...ios106MacRequirement, + buildKinds: { + dev: { stableMinVersion: "0.64.0" }, + beta: ios106MacRequirement, + internal: ios106MacRequirement, + demo: { stableMinVersion: "0.64.23" }, + prod: ios106MacRequirement, + }, + }, ], }; diff --git a/web/data/whats-new.ts b/web/data/whats-new.ts index 402afbead450..dc0025517d7f 100644 --- a/web/data/whats-new.ts +++ b/web/data/whats-new.ts @@ -1,3 +1,5 @@ +import { ios106MacRequirement } from "./mobile-mac-compat"; + /** * The remote What's New list served by GET /api/whats-new. * @@ -84,5 +86,34 @@ export const whatsNewList: WhatsNewList = { // compatibility floors, and the connection notes. The earlier standalone // pairing page is intentionally absent and can never be shown again. visibleEntryIds: ["connections.v2", "connections.v1"], - announcements: [], + announcements: [ + { + id: "ios-1.0.6-connections", + minVersion: "1.0.6", + maxVersion: "1.0.6", + channels: ["beta", "internal"], + title: "What's New in 1.0.6", + releaseLabel: "1.0.6 · September 2026", + features: [ + { + symbol: "network", + title: "Updated Mac connections", + detail: + "This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect.", + }, + { + symbol: "arrow.down.circle", + title: "Update cmux on your Mac", + detail: + `Requires cmux ${ios106MacRequirement.stableMinVersion} or later, or NIGHTLY ${ios106MacRequirement.nightly.minBaseVersion}-nightly.${ios106MacRequirement.nightly.minBuild} or later. Enable iOS pairing in Settings > Mobile on each Mac.`, + }, + { + symbol: "clock.arrow.circlepath", + title: "Need to keep an older Mac build?", + detail: + "Use cmux BETA 1.0.4 (20260817224846) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service.", + }, + ], + }, + ], }; From 169bcb6961fedd09e49caed297e4e7abada43e14 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:51:24 -0700 Subject: [PATCH 03/12] verify 1.0.6 notice in the iOS simulator --- .../Debug/MobileWhatsNewPreviewView.swift | 6 +++ ios/cmuxUITests/cmuxUITests.swift | 53 +++++++++++++++++++ web/tests/whats-new-route.test.ts | 6 +++ 3 files changed, 65 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift index 990d685d39f1..64e20487a6ae 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift @@ -36,6 +36,12 @@ public struct MobileWhatsNewPreviewView: View { NavigationStack { MobileWhatsNewListView() } + .overlay(alignment: .bottom) { + if hasRemoteFixture && center.lastRefreshSucceeded { + Text("Remote notice loaded") + .accessibilityIdentifier("MobileWhatsNewPreviewLoaded") + } + } .environment(center) .task { guard hasRemoteFixture else { return } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 91caaca6eeed..c1fee0c469d0 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -100,6 +100,59 @@ final class cmuxUITests: XCTestCase { XCTAssertTrue(show.waitForExistence(timeout: 5)) } + @MainActor + func testIOS106WhatsNewReleaseNotice() throws { + // Snapshot of web/data/whats-new.ts; the web test checks it stays in sync. + let payload = #"{"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[{"id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6","channels":["beta","internal"],"title":"What's New in 1.0.6","releaseLabel":"1.0.6 · September 2026","features":[{"symbol":"network","title":"Updated Mac connections","detail":"This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect."},{"symbol":"arrow.down.circle","title":"Update cmux on your Mac","detail":"Requires cmux 0.64.25 or later, or NIGHTLY 0.64.25-nightly.3522337919701 or later. Enable iOS pairing in Settings > Mobile on each Mac."},{"symbol":"clock.arrow.circlepath","title":"Need to keep an older Mac build?","detail":"Use cmux BETA 1.0.4 (20260817224846) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service."}]}]}"# + let app = XCUIApplication() + defer { app.terminate() } + for (channel, version, appearance, expected) in [ + ("beta", "1.0.6", "light", true), + ("internal", "1.0.6", "dark", true), + ("prod", "1.0.6", "light", false), + ("demo", "1.0.6", "light", false), + ("dev", "1.0.6", "light", false), + ("beta", "1.0.5", "light", false), + ("beta", "1.0.7", "light", false) + ] { + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment = [ + "CMUX_UITEST_WHATS_NEW_PREVIEW": "1", + "CMUX_UITEST_WHATS_NEW_PAYLOAD": payload, + "CMUX_UITEST_WHATS_NEW_CHANNEL": channel, + "CMUX_UITEST_WHATS_NEW_VERSION": version, + "CMUX_UITEST_WHATS_NEW_APPEARANCE": appearance, + "CMUX_UITEST_WHATS_NEW_ACKNOWLEDGED_ENTRY_ID": "connections.v2" + ] + app.launch() + let title = app.staticTexts["What's New in 1.0.6"].firstMatch + if expected { + XCTAssertTrue(title.waitForExistence(timeout: 15)) + let requirement = app.staticTexts.matching(NSPredicate( + format: "label CONTAINS %@", "0.64.25-nightly.3522337919701" + )).firstMatch + XCTAssertTrue(requirement.exists) + XCTAssertTrue(requirement.label.contains("cmux 0.64.25 or later")) + let rollback = app.staticTexts.matching(NSPredicate( + format: "label CONTAINS %@", "20260817224846" + )).firstMatch + XCTAssertTrue(rollback.exists) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "1.0.6 notice - \(channel) - \(appearance)" + screenshot.lifetime = .keepAlways + add(screenshot) + let continueButton = app.buttons["MobileWhatsNewContinue"].firstMatch + XCTAssertTrue(continueButton.isHittable) + continueButton.tap() + XCTAssertTrue(app.staticTexts["MobileWhatsNewPreviewLoaded"].waitForExistence(timeout: 5)) + } else { + XCTAssertTrue(app.staticTexts["MobileWhatsNewPreviewLoaded"].waitForExistence(timeout: 15)) + XCTAssertFalse(title.exists, "Notice must not reach \(channel) \(version)") + } + app.terminate() + } + } + @MainActor func testWhatsNewSheetFitsSwipedPageAndMatchesAppearance() throws { let app = XCUIApplication() diff --git a/web/tests/whats-new-route.test.ts b/web/tests/whats-new-route.test.ts index d09e6e71e497..4d21dc82f01b 100644 --- a/web/tests/whats-new-route.test.ts +++ b/web/tests/whats-new-route.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { whatsNewList, type WhatsNewList } from "../data/whats-new"; @@ -142,4 +143,9 @@ describe("whats-new route channel targeting", () => { ]), }); }); + + test("the simulator exercises the shipped announcement payload", () => { + const source = readFileSync(new URL("../../ios/cmuxUITests/cmuxUITests.swift", import.meta.url), "utf8"); + expect(source).toContain(`let payload = #"${JSON.stringify(whatsNewList)}"#`); + }); }); From b2e5305c6e8fca9c7021b49ab1d18ecb9274dd47 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:29:49 -0700 Subject: [PATCH 04/12] correct 1.0.6 rollback and pairing notice --- .../MobileMacCompatPolicy.swift | 2 +- .../MobileWhatsNewCatalog.swift | 12 +++-- .../MobileWhatsNewCenter.swift | 9 +++- .../Resources/Localizable.xcstrings | 18 +++---- .../MobileWhatsNewChannelGateTests.swift | 34 +++++++++++- ios/CHANGELOG.md | 2 +- ios/cmux/Resources/Localizable.xcstrings | 54 +++++++++---------- ios/cmuxUITests/cmuxUITests.swift | 12 +++-- web/data/mobile-mac-compat.ts | 4 +- web/data/whats-new.ts | 2 +- 10 files changed, 100 insertions(+), 49 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift index de4affa29c83..8d86f3ead471 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift @@ -30,7 +30,7 @@ public struct MobileMacCompatPolicy: Equatable, Sendable { /// transport, 0.64.20 for authenticated Iroh, and 0.64.23 for the rebuilt /// Iroh transport. The App Store lane requires 0.64.25 stable or the first /// published 0.64.25 nightly in every tier. The - /// BETA 1.0.4 build 20260817224846 is the last older-compatible build; + /// BETA 1.0.5 build 20260914204800 is the last older-compatible build; /// the later INTERNAL 1.0.4 cut uses the rebuilt transport. public static let baked: MobileMacCompatPolicy = { guard let minIOS = MobileMacAppVersion(parsing: "1.0.0"), diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift index 7d286d9e48df..8789c70e8217 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift @@ -48,6 +48,10 @@ struct MobileWhatsNewPage: Identifiable { /// binary change. Only meaningful for binary catalog entries; resolved /// announcements are channel-filtered before page construction. var channels: [String]? = nil + /// Optional inclusive app-version bounds for binary pages. The 1.0.6 + /// pairing setup page is deliberately version-pinned. + var minVersion: String? = nil + var maxVersion: String? = nil /// One quiet line under the feature rows (compatibility notes and other /// fine print that must not compete with the features). `nil` hides it. var footnote: String? = nil @@ -118,14 +122,16 @@ struct MobileWhatsNewCatalog: Sendable { id: "connections.v2", releaseLabel: L10n.string( "mobile.pairingOptInUpdate.releaseLabel", - defaultValue: "1.0.4 · September 2026" + defaultValue: "1.0.6 · September 2026" ), title: L10n.string( "mobile.whatsNew.pairing.pageTitle", defaultValue: "Action Required: Enable iOS pairing on your Mac" ), body: .pairingSetup([]), - isAnnouncement: false + isAnnouncement: false, + minVersion: "1.0.6", + maxVersion: "1.0.6" ) } @@ -232,7 +238,7 @@ struct MobileWhatsNewCatalog: Sendable { return String( format: L10n.string( "mobile.connectionsUpdate.macUpdate.detail", - defaultValue: "Use cmux %@ or later. Older Macs: use BETA 1.0.4 (20260817224846)." + defaultValue: "Use cmux %@ or later. Older Macs: use BETA 1.0.5 (20260914204800)." ), version ) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift index 47404f806199..4f007c4ac0a8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift @@ -147,10 +147,17 @@ public final class MobileWhatsNewCenter { /// catches up. An explicit empty list remains a deliberate retraction. var visibleBinaryEntries: [MobileWhatsNewPage] { let channelAllowed = MobileWhatsNewCatalog().entries.filter { page in - MobileWhatsNewChannelPolicy().isVisible( + let channelVisible = MobileWhatsNewChannelPolicy().isVisible( channelTokens: remoteList?.entryChannels?[page.id] ?? page.channels, buildType: buildType ) + guard channelVisible else { return false } + guard let minVersion = page.minVersion else { return true } + return MobileAppVersionCompare().version( + appVersion, + isWithinMin: minVersion, + max: page.maxVersion + ) } guard let remoteList else { return channelAllowed } let visible = Set(remoteList.visibleEntryIds) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index a291222c1bfb..7ddc05da1105 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -13810,15 +13810,15 @@ "mobile.pairingOptInUpdate.releaseLabel": { "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "1.0.4 · September 2026"}}, - "ja": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026年9月"}}, - "de": {"stringUnit": {"state": "translated", "value": "1.0.4 · September 2026"}}, - "fr": {"stringUnit": {"state": "translated", "value": "1.0.4 · septembre 2026"}}, - "ar": {"stringUnit": {"state": "translated", "value": "1.0.4 · سبتمبر 2026"}}, - "es": {"stringUnit": {"state": "translated", "value": "1.0.4 · septiembre de 2026"}}, - "zh-Hant": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026 年 9 月"}}, - "zh-Hans": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026 年 9 月"}}, - "ko": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026년 9월"}} + "en": {"stringUnit": {"state": "translated", "value": "1.0.6 · September 2026"}}, + "ja": {"stringUnit": {"state": "translated", "value": "1.0.6 · 2026年9月"}}, + "de": {"stringUnit": {"state": "translated", "value": "1.0.6 · September 2026"}}, + "fr": {"stringUnit": {"state": "translated", "value": "1.0.6 · septembre 2026"}}, + "ar": {"stringUnit": {"state": "translated", "value": "1.0.6 · سبتمبر 2026"}}, + "es": {"stringUnit": {"state": "translated", "value": "1.0.6 · septiembre de 2026"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "1.0.6 · 2026 年 9 月"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "1.0.6 · 2026 年 9 月"}}, + "ko": {"stringUnit": {"state": "translated", "value": "1.0.6 · 2026년 9월"}} } }, "mobile.whatsNew.pairing.pageTitle": { diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift index bc9069e480bf..839a61bfe64e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift @@ -18,7 +18,7 @@ import Testing buildType: MobileBuildType, payload: String? = nil, acknowledgedEntryID: String? = nil, - appVersion: String = "1.0.5" + appVersion: String = "1.0.6" ) -> MobileWhatsNewCenter { let suiteName = "MobileWhatsNewChannelGateTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -80,6 +80,38 @@ import Testing } } + @Test func announcementIsFollowedByPairingSetupOnlyOn106() async throws { + let payload = #""" + {"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[{ + "id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6", + "channels":["beta","internal"],"title":"What's New in 1.0.6", + "features":[{"title":"Update your Mac","detail":"Requires cmux 0.64.25."}] + }]} + """# + let beta = makeCenter( + buildType: .beta, + payload: payload, + acknowledgedEntryID: "connections.v1", + appVersion: "1.0.6" + ) + await beta.refresh() + #expect(beta.unseenPages.map(\.id) == ["ios-1.0.6-connections", "connections.v2"]) + let pairing = try #require(beta.unseenPages.last) + #expect(pairing.releaseLabel == "1.0.6 · September 2026") + #expect(pairing.minVersion == "1.0.6") + #expect(pairing.maxVersion == "1.0.6") + + let oldBeta = makeCenter( + buildType: .beta, + payload: payload, + acknowledgedEntryID: "connections.v1", + appVersion: "1.0.5" + ) + await oldBeta.refresh() + #expect(oldBeta.unseenPages.isEmpty) + #expect(oldBeta.visibleBinaryEntries.map(\.id) == ["connections.v1"]) + } + @Test func pairingUpdateAppearsAfterAnOlderPageWasAcknowledged() async { let payload = #""" { diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 7027c110b3b2..483e2f61107a 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -55,7 +55,7 @@ for a different version), so bump the beta version with - Updated the Mac connection service and Iroh transport. - Requires cmux 0.64.25 or later, or NIGHTLY 0.64.25-nightly.3522337919701 or later. Enable iOS pairing in Settings > Mobile on each Mac. -- If you cannot update your Mac, use cmux BETA 1.0.4 (20260817224846) in TestFlight > Previous Builds while available. Choose that exact build. +- If you cannot update your Mac, use cmux BETA 1.0.5 (20260914204800) in TestFlight > Previous Builds while available. Choose that exact build. --- diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 77d0555a9575..2ea2ea6ae347 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -83714,55 +83714,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "1.0.4 · September 2026" + "value": "1.0.6 · September 2026" } }, "ja": { "stringUnit": { "state": "translated", - "value": "1.0.4 · 2026年9月" + "value": "1.0.6 · 2026年9月" } }, "de": { "stringUnit": { "state": "translated", - "value": "1.0.4 · September 2026" + "value": "1.0.6 · September 2026" } }, "fr": { "stringUnit": { "state": "translated", - "value": "1.0.4 · septembre 2026" + "value": "1.0.6 · septembre 2026" } }, "ar": { "stringUnit": { "state": "translated", - "value": "1.0.4 · سبتمبر 2026" + "value": "1.0.6 · سبتمبر 2026" } }, "es": { "stringUnit": { "state": "translated", - "value": "1.0.4 · septiembre de 2026" + "value": "1.0.6 · septiembre de 2026" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "1.0.4 · 2026 年 9 月" + "value": "1.0.6 · 2026 年 9 月" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "1.0.4 · 2026 年 9 月" + "value": "1.0.6 · 2026 年 9 月" } }, "ko": { "stringUnit": { "state": "translated", - "value": "1.0.4 · 2026년 9월" + "value": "1.0.6 · 2026년 9월" } } } @@ -85189,55 +85189,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "Use cmux %@ or later. Older Macs: use BETA 1.0.4 (20260817224846)." + "value": "Use cmux %@ or later. Older Macs: use BETA 1.0.5 (20260914204800)." } }, "ja": { "stringUnit": { "state": "translated", - "value": "この iPhone アップデートは新しい接続プロトコルを使用し、アップデート済みの Mac としかペアリングできません。接続する前に、Mac の cmux を %@ にアップデートしてください。まだ Mac をアップデートしない場合は、旧バージョンの Mac で動作する最後のバージョンである cmux BETA TestFlight 1.0.4 (20260817224846) をそのままご利用ください(または戻してください)。" + "value": "この iPhone アップデートは新しい接続プロトコルを使用し、アップデート済みの Mac としかペアリングできません。接続する前に、Mac の cmux を %@ にアップデートしてください。まだ Mac をアップデートしない場合は、旧バージョンの Mac で動作する最後のバージョンである cmux BETA TestFlight 1.0.5 (20260914204800) をそのままご利用ください(または戻してください)。" } }, "de": { "stringUnit": { "state": "translated", - "value": "Dieses iPhone-Update spricht ein neues Verbindungsprotokoll und lässt sich nur mit einem aktualisierten Mac koppeln. Aktualisieren Sie cmux auf Ihrem Mac auf %@, bevor Sie eine Verbindung herstellen. Sie sind nicht bereit, Ihren Mac zu aktualisieren? Bleiben Sie bei cmux BETA TestFlight Version 1.0.4 (20260817224846) (oder kehren Sie zu ihr zurück), der letzten Version, die mit älteren Macs funktioniert." + "value": "Dieses iPhone-Update spricht ein neues Verbindungsprotokoll und lässt sich nur mit einem aktualisierten Mac koppeln. Aktualisieren Sie cmux auf Ihrem Mac auf %@, bevor Sie eine Verbindung herstellen. Sie sind nicht bereit, Ihren Mac zu aktualisieren? Bleiben Sie bei cmux BETA TestFlight Version 1.0.5 (20260914204800) (oder kehren Sie zu ihr zurück), der letzten Version, die mit älteren Macs funktioniert." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Cette mise à jour de l'iPhone utilise un nouveau protocole de connexion et s'associe uniquement à un Mac mis à jour. Mettez à jour cmux sur votre Mac vers %@ avant de vous connecter. Pas prêt à mettre à jour votre Mac ? Restez sur (ou revenez à) cmux BETA TestFlight version 1.0.4 (20260817224846), la dernière version qui fonctionne avec les anciens Mac." + "value": "Cette mise à jour de l'iPhone utilise un nouveau protocole de connexion et s'associe uniquement à un Mac mis à jour. Mettez à jour cmux sur votre Mac vers %@ avant de vous connecter. Pas prêt à mettre à jour votre Mac ? Restez sur (ou revenez à) cmux BETA TestFlight version 1.0.5 (20260914204800), la dernière version qui fonctionne avec les anciens Mac." } }, "ar": { "stringUnit": { "state": "translated", - "value": "يتحدث تحديث iPhone هذا عن بروتوكول اتصال جديد ولا يقترن إلا بجهاز Mac محدث. قم بتحديث cmux على جهاز Mac الخاص بك إلى %@ قبل الاتصال. لست مستعدًا لتحديث جهاز Mac الخاص بك؟ استمر (أو ارجع إلى) إصدار cmux BETA TestFlight 1.0.4 (20260817224846)، الإصدار الأخير الذي يعمل مع أجهزة Mac الأقدم." + "value": "يتحدث تحديث iPhone هذا عن بروتوكول اتصال جديد ولا يقترن إلا بجهاز Mac محدث. قم بتحديث cmux على جهاز Mac الخاص بك إلى %@ قبل الاتصال. لست مستعدًا لتحديث جهاز Mac الخاص بك؟ استمر (أو ارجع إلى) إصدار cmux BETA TestFlight 1.0.5 (20260914204800)، الإصدار الأخير الذي يعمل مع أجهزة Mac الأقدم." } }, "es": { "stringUnit": { "state": "translated", - "value": "Esta actualización de iPhone utiliza un nuevo protocolo de conexión y solo se empareja con una Mac actualizada. Actualice cmux en su Mac a %@ antes de conectarse. ¿No estás listo para actualizar tu Mac? Permanezca (o vuelva a) cmux BETA TestFlight versión 1.0.4 (20260817224846), la última versión que funciona con Mac más antiguas." + "value": "Esta actualización de iPhone utiliza un nuevo protocolo de conexión y solo se empareja con una Mac actualizada. Actualice cmux en su Mac a %@ antes de conectarse. ¿No estás listo para actualizar tu Mac? Permanezca (o vuelva a) cmux BETA TestFlight versión 1.0.5 (20260914204800), la última versión que funciona con Mac más antiguas." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "此 iPhone 更新採用新的連接協議,並且僅與更新的 Mac 配對。在連接之前,將 Mac 上的 cmux 更新為 %@。還沒準備好更新您的 Mac?保留(或恢復到)cmux BETA TestFlight 版本 1.0.4 (20260817224846),這是適用於舊版 Mac 的最後一個版本。" + "value": "此 iPhone 更新採用新的連接協議,並且僅與更新的 Mac 配對。在連接之前,將 Mac 上的 cmux 更新為 %@。還沒準備好更新您的 Mac?保留(或恢復到)cmux BETA TestFlight 版本 1.0.5 (20260914204800),這是適用於舊版 Mac 的最後一個版本。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "此 iPhone 更新采用新的连接协议,并且仅与更新的 Mac 配对。连接之前,将 Mac 上的 cmux 更新为 %@。还没准备好更新您的 Mac?保留(或恢复到)cmux BETA TestFlight 版本 1.0.4 (20260817224846),这是适用于旧版 Mac 的最后一个版本。" + "value": "此 iPhone 更新采用新的连接协议,并且仅与更新的 Mac 配对。连接之前,将 Mac 上的 cmux 更新为 %@。还没准备好更新您的 Mac?保留(或恢复到)cmux BETA TestFlight 版本 1.0.5 (20260914204800),这是适用于旧版 Mac 的最后一个版本。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 iPhone 업데이트는 새로운 연결 프로토콜을 사용하며 업데이트된 Mac과만 페어링됩니다. 연결하기 전에 Mac의 cmux를 %@로 업데이트하세요. 아직 Mac을 업데이트할 준비가 되지 않으셨나요? 이전 Mac에서 작동하는 마지막 버전인 cmux BETA TestFlight 버전 1.0.4(20260817224846)를 유지하거나 이전 버전으로 되돌립니다." + "value": "이 iPhone 업데이트는 새로운 연결 프로토콜을 사용하며 업데이트된 Mac과만 페어링됩니다. 연결하기 전에 Mac의 cmux를 %@로 업데이트하세요. 아직 Mac을 업데이트할 준비가 되지 않으셨나요? 이전 Mac에서 작동하는 마지막 버전인 cmux BETA TestFlight 버전 1.0.5(20260914204800)를 유지하거나 이전 버전으로 되돌립니다." } } } @@ -85307,55 +85307,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "Not ready? Stay on (or revert to) cmux BETA 1.0.4 (20260817224846)." + "value": "Not ready? Stay on (or revert to) cmux BETA 1.0.5 (20260914204800)." } }, "ja": { "stringUnit": { "state": "translated", - "value": "まだの場合は、cmux BETA 1.0.4 (20260817224846) をそのままご利用ください(または戻してください)。" + "value": "まだの場合は、cmux BETA 1.0.5 (20260914204800) をそのままご利用ください(または戻してください)。" } }, "de": { "stringUnit": { "state": "translated", - "value": "Noch nicht bereit? Bleiben Sie bei cmux BETA 1.0.4 (20260817224846) oder kehren Sie dorthin zurück." + "value": "Noch nicht bereit? Bleiben Sie bei cmux BETA 1.0.5 (20260914204800) oder kehren Sie dorthin zurück." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Pas encore prêt ? Restez sur cmux BETA 1.0.4 (20260817224846) ou revenez-y." + "value": "Pas encore prêt ? Restez sur cmux BETA 1.0.5 (20260914204800) ou revenez-y." } }, "ar": { "stringUnit": { "state": "translated", - "value": "لست مستعدًا بعد؟ ابقَ على cmux BETA 1.0.4 (20260817224846) أو ارجع إليه." + "value": "لست مستعدًا بعد؟ ابقَ على cmux BETA 1.0.5 (20260914204800) أو ارجع إليه." } }, "es": { "stringUnit": { "state": "translated", - "value": "¿Aún no? Quédate en cmux BETA 1.0.4 (20260817224846) o vuelve a esa versión." + "value": "¿Aún no? Quédate en cmux BETA 1.0.5 (20260914204800) o vuelve a esa versión." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "還沒準備好?可以繼續使用(或還原至)cmux BETA 1.0.4 (20260817224846)。" + "value": "還沒準備好?可以繼續使用(或還原至)cmux BETA 1.0.5 (20260914204800)。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "还没准备好?可以继续使用(或还原至)cmux BETA 1.0.4 (20260817224846)。" + "value": "还没准备好?可以继续使用(或还原至)cmux BETA 1.0.5 (20260914204800)。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "아직 준비되지 않았나요? cmux BETA 1.0.4 (20260817224846)를 계속 사용하거나 해당 버전으로 되돌리세요." + "value": "아직 준비되지 않았나요? cmux BETA 1.0.5 (20260914204800)를 계속 사용하거나 해당 버전으로 되돌리세요." } } } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index c1fee0c469d0..dbe262d777cf 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -103,7 +103,7 @@ final class cmuxUITests: XCTestCase { @MainActor func testIOS106WhatsNewReleaseNotice() throws { // Snapshot of web/data/whats-new.ts; the web test checks it stays in sync. - let payload = #"{"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[{"id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6","channels":["beta","internal"],"title":"What's New in 1.0.6","releaseLabel":"1.0.6 · September 2026","features":[{"symbol":"network","title":"Updated Mac connections","detail":"This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect."},{"symbol":"arrow.down.circle","title":"Update cmux on your Mac","detail":"Requires cmux 0.64.25 or later, or NIGHTLY 0.64.25-nightly.3522337919701 or later. Enable iOS pairing in Settings > Mobile on each Mac."},{"symbol":"clock.arrow.circlepath","title":"Need to keep an older Mac build?","detail":"Use cmux BETA 1.0.4 (20260817224846) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service."}]}]}"# + let payload = #"{"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[{"id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6","channels":["beta","internal"],"title":"What's New in 1.0.6","releaseLabel":"1.0.6 · September 2026","features":[{"symbol":"network","title":"Updated Mac connections","detail":"This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect."},{"symbol":"arrow.down.circle","title":"Update cmux on your Mac","detail":"Requires cmux 0.64.25 or later, or NIGHTLY 0.64.25-nightly.3522337919701 or later. Enable iOS pairing in Settings > Mobile on each Mac."},{"symbol":"clock.arrow.circlepath","title":"Need to keep an older Mac build?","detail":"Use cmux BETA 1.0.5 (20260914204800) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service."}]}]}"# let app = XCUIApplication() defer { app.terminate() } for (channel, version, appearance, expected) in [ @@ -122,7 +122,7 @@ final class cmuxUITests: XCTestCase { "CMUX_UITEST_WHATS_NEW_CHANNEL": channel, "CMUX_UITEST_WHATS_NEW_VERSION": version, "CMUX_UITEST_WHATS_NEW_APPEARANCE": appearance, - "CMUX_UITEST_WHATS_NEW_ACKNOWLEDGED_ENTRY_ID": "connections.v2" + "CMUX_UITEST_WHATS_NEW_ACKNOWLEDGED_ENTRY_ID": "connections.v1" ] app.launch() let title = app.staticTexts["What's New in 1.0.6"].firstMatch @@ -134,7 +134,7 @@ final class cmuxUITests: XCTestCase { XCTAssertTrue(requirement.exists) XCTAssertTrue(requirement.label.contains("cmux 0.64.25 or later")) let rollback = app.staticTexts.matching(NSPredicate( - format: "label CONTAINS %@", "20260817224846" + format: "label CONTAINS %@", "20260914204800" )).firstMatch XCTAssertTrue(rollback.exists) let screenshot = XCTAttachment(screenshot: app.screenshot()) @@ -144,6 +144,12 @@ final class cmuxUITests: XCTestCase { let continueButton = app.buttons["MobileWhatsNewContinue"].firstMatch XCTAssertTrue(continueButton.isHittable) continueButton.tap() + let pairingSheet = app.collectionViews["MobileWhatsNewSheet"].firstMatch + XCTAssertTrue(pairingSheet.waitForExistence(timeout: 5)) + XCTAssertTrue(pairingSheet.staticTexts["Action Required: Enable iOS pairing on your Mac"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["1.0.6 · September 2026"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.images["cmux Mac Settings, Mobile section, showing Enable iOS pairing."].waitForExistence(timeout: 5)) + continueButton.tap() XCTAssertTrue(app.staticTexts["MobileWhatsNewPreviewLoaded"].waitForExistence(timeout: 5)) } else { XCTAssertTrue(app.staticTexts["MobileWhatsNewPreviewLoaded"].waitForExistence(timeout: 15)) diff --git a/web/data/mobile-mac-compat.ts b/web/data/mobile-mac-compat.ts index dd38424df239..67a05db9a6cd 100644 --- a/web/data/mobile-mac-compat.ts +++ b/web/data/mobile-mac-compat.ts @@ -13,8 +13,8 @@ * 0.64.20, and the rebuilt Iroh transport with 0.64.23. Every App Store * version (the `prod` build kind, starting at 1.0.0) now requires stable * 0.64.25 or the first published 0.64.25 nightly. Older TestFlight versions - * retain the Mac releases they can actually use. In particular, BETA 1.0.4 - * build 20260817224846 is the last build that works with older Macs, while + * retain the Mac releases they can actually use. In particular, BETA 1.0.5 + * build 20260914204800 is the last build that works with older Macs, while * the later INTERNAL 1.0.4 cut uses the rebuilt transport and needs 0.64.23. * Binaries built before the gate shipped ignore this list entirely, so * covering their versions is harmless. diff --git a/web/data/whats-new.ts b/web/data/whats-new.ts index dc0025517d7f..1fa69b7bc235 100644 --- a/web/data/whats-new.ts +++ b/web/data/whats-new.ts @@ -111,7 +111,7 @@ export const whatsNewList: WhatsNewList = { symbol: "clock.arrow.circlepath", title: "Need to keep an older Mac build?", detail: - "Use cmux BETA 1.0.4 (20260817224846) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service.", + "Use cmux BETA 1.0.5 (20260914204800) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service.", }, ], }, From 8757abd16f63ac93d72fdadf7c47b2a9eadd1aad Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:38:48 -0700 Subject: [PATCH 05/12] allow open ended whats new version gates --- .../Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift index 400aa5edff3f..e7547034ebdc 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift @@ -110,10 +110,11 @@ struct MobileAppVersionCompare: Sendable { func version( _ version: String, isWithinMin minVersion: String, - max maxVersion: String + max maxVersion: String? ) -> Bool { - compare(version, minVersion) != .orderedAscending - && compare(version, maxVersion) != .orderedDescending + guard compare(version, minVersion) != .orderedAscending else { return false } + guard let maxVersion else { return true } + return compare(version, maxVersion) != .orderedDescending } private func components(of version: String) -> [Int] { From c8baba55042dc180d7b06c1c35ccde0bab056183 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:41:53 -0700 Subject: [PATCH 06/12] show pairing guidance again for 1.0.6 upgrades --- .../Debug/MobileWhatsNewPreviewView.swift | 4 +- .../MobileWhatsNewCatalog.swift | 37 +++++-- .../MobileWhatsNewCenter.swift | 16 +-- .../MobileWhatsNewContent.swift | 13 ++- .../Resources/Localizable.xcstrings | 14 +++ .../MobileWhatsNewChannelGateTests.swift | 102 ++++++++++-------- ios/cmux/Resources/Localizable.xcstrings | 59 ++++++++++ ios/cmuxUITests/cmuxUITests.swift | 16 ++- scripts/localization-allowed-omissions.json | 6 ++ web/data/whats-new.ts | 2 +- web/tests/whats-new-route.test.ts | 2 +- 11 files changed, 194 insertions(+), 77 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift index 64e20487a6ae..16990cd12164 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift @@ -22,8 +22,8 @@ public struct MobileWhatsNewPreviewView: View { _showsSheet = State(initialValue: payload == nil) _center = State(initialValue: MobileWhatsNewCenter( apiBaseURL: "https://cmux.test", - appVersion: environment["CMUX_UITEST_WHATS_NEW_VERSION"], - buildType: MobileBuildType(rawValue: environment["CMUX_UITEST_WHATS_NEW_CHANNEL"] ?? "beta") ?? .beta, + appVersion: environment["CMUX_UITEST_WHATS_NEW_VERSION"] ?? "1.0.5", + buildType: MobileBuildType(rawValue: environment["CMUX_UITEST_WHATS_NEW_CHANNEL"] ?? "dev") ?? .dev, defaults: defaults, loader: { _ in guard let payload else { throw URLError(.notConnectedToInternet) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift index 8789c70e8217..1bc903eef3a1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift @@ -63,6 +63,10 @@ struct MobileWhatsNewPage: Identifiable { var listID: String { (isAnnouncement ? "announcement:" : "entry:") + id } + + func supports(appVersion: String) -> Bool { + MobileAppVersionCompare().version(appVersion, isWithinMin: minVersion ?? "0", max: maxVersion) + } } /// Version-keyed release notes compiled into this binary, newest first. @@ -77,7 +81,7 @@ struct MobileWhatsNewCatalog: Sendable { /// Newest first. The one-time sheet shows every visible entry newer than /// the acknowledgement marker. var entries: [MobileWhatsNewPage] { - [pairingOptInUpdate, connectionsUpdate] + [ios106PairingUpdate, pairingOptInUpdate, connectionsUpdate] } func entry(withID id: String) -> MobileWhatsNewPage? { @@ -90,10 +94,11 @@ struct MobileWhatsNewCatalog: Sendable { /// use it, so an official App Store build renders NO What's New surface /// before its first fetch, while team builds keep the full catalog. func channelVisibleEntries( - buildType: MobileBuildType = .current() + buildType: MobileBuildType = .current(), + appVersion: String = AppVersionInfo.current().marketingVersion ) -> [MobileWhatsNewPage] { entries.filter { page in - MobileWhatsNewChannelPolicy().isVisible( + page.supports(appVersion: appVersion) && MobileWhatsNewChannelPolicy().isVisible( channelTokens: page.channels, buildType: buildType ) @@ -111,17 +116,17 @@ struct MobileWhatsNewCatalog: Sendable { // sits between the current pairing page and the older connection page. switch id { case "pairing-opt-in.v1": - return 1 + return entries.firstIndex(where: { $0.id == "connections.v1" }) default: return nil } } - var pairingOptInUpdate: MobileWhatsNewPage { + var ios106PairingUpdate: MobileWhatsNewPage { MobileWhatsNewPage( - id: "connections.v2", + id: "pairing.1.0.6", releaseLabel: L10n.string( - "mobile.pairingOptInUpdate.releaseLabel", + "mobile.ios106PairingUpdate.releaseLabel", defaultValue: "1.0.6 · September 2026" ), title: L10n.string( @@ -130,11 +135,29 @@ struct MobileWhatsNewCatalog: Sendable { ), body: .pairingSetup([]), isAnnouncement: false, + channels: ["beta", "internal"], minVersion: "1.0.6", maxVersion: "1.0.6" ) } + var pairingOptInUpdate: MobileWhatsNewPage { + MobileWhatsNewPage( + id: "connections.v2", + releaseLabel: L10n.string( + "mobile.pairingOptInUpdate.releaseLabel", + defaultValue: "1.0.4 · September 2026" + ), + title: L10n.string( + "mobile.whatsNew.pairing.pageTitle", + defaultValue: "Action Required: Enable iOS pairing on your Mac" + ), + body: .pairingSetup([]), + isAnnouncement: false, + maxVersion: "1.0.5" + ) + } + var connectionsUpdate: MobileWhatsNewPage { MobileWhatsNewPage( id: "connections.v1", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift index 4f007c4ac0a8..6767430674c0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift @@ -32,15 +32,15 @@ public final class MobileWhatsNewCenter { static let requestPath = "/api/whats-new" /// The pairing requirement is part of the client contract, so an older /// cached visibility list must not hide it from team builds. - private static let requiredBinaryEntryIDs: Set = ["connections.v2"] + private static let requiredBinaryEntryIDs: Set = ["pairing.1.0.6", "connections.v2"] private let requestURL: URL? - private let appVersion: String + let appVersion: String /// The running distribution channel, gating every page through /// ``MobileWhatsNewChannelPolicy``: official (`.prod`) and demo builds /// see a page only when it explicitly lists their channel token, so the /// App Store app shows no What's New surface by default (Guideline 2.2). - private let buildType: MobileBuildType + let buildType: MobileBuildType private let defaults: UserDefaults private let loader: Loader @@ -151,18 +151,12 @@ public final class MobileWhatsNewCenter { channelTokens: remoteList?.entryChannels?[page.id] ?? page.channels, buildType: buildType ) - guard channelVisible else { return false } - guard let minVersion = page.minVersion else { return true } - return MobileAppVersionCompare().version( - appVersion, - isWithinMin: minVersion, - max: page.maxVersion - ) + return channelVisible && page.supports(appVersion: appVersion) } guard let remoteList else { return channelAllowed } let visible = Set(remoteList.visibleEntryIds) guard !visible.isEmpty else { return [] } - let recognized = visible.intersection(Set(channelAllowed.map(\.id))) + let recognized = visible.intersection(Set(MobileWhatsNewCatalog().entries.map(\.id))) guard !recognized.isEmpty else { return channelAllowed } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift index 9f1262cd375d..d35f71058de3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift @@ -205,12 +205,17 @@ struct MobileWhatsNewPairingSetupContent: View { let page: MobileWhatsNewPage let layout: MobileWhatsNewPageLayout @Environment(MobileMacCompatCenter.self) private var macCompatCenter: MobileMacCompatCenter? + @Environment(MobileWhatsNewCenter.self) private var whatsNewCenter: MobileWhatsNewCenter? + + private var buildType: MobileBuildType { + whatsNewCenter?.buildType ?? .current() + } private var compatibility: MobileWhatsNewMacCompatibility { MobileWhatsNewCatalog().macCompatibility( policy: macCompatCenter?.policy ?? .baked, - iosVersion: AppVersionInfo.current().marketingVersion, - buildType: MobileBuildType.current() + iosVersion: whatsNewCenter?.appVersion ?? AppVersionInfo.current().marketingVersion, + buildType: buildType ) } @@ -311,9 +316,9 @@ struct MobileWhatsNewPairingSetupContent: View { ) } - if MobileBuildType.current().usesInternalBuildVocabulary { + if buildType.usesInternalBuildVocabulary { Text(MobileWhatsNewCatalog().macUpdateDetail( - buildType: MobileBuildType.current(), + buildType: buildType, requiredVersion: compatibility.stableVersion )) .font(.footnote) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index 7ddc05da1105..c398ce36460c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -13808,6 +13808,20 @@ } }, "mobile.pairingOptInUpdate.releaseLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "1.0.4 · September 2026"}}, + "ja": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026年9月"}}, + "de": {"stringUnit": {"state": "translated", "value": "1.0.4 · September 2026"}}, + "fr": {"stringUnit": {"state": "translated", "value": "1.0.4 · septembre 2026"}}, + "ar": {"stringUnit": {"state": "translated", "value": "1.0.4 · سبتمبر 2026"}}, + "es": {"stringUnit": {"state": "translated", "value": "1.0.4 · septiembre de 2026"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026 年 9 月"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026 年 9 月"}}, + "ko": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026년 9월"}} + } + }, + "mobile.ios106PairingUpdate.releaseLabel": { "extractionState": "manual", "localizations": { "en": {"stringUnit": {"state": "translated", "value": "1.0.6 · September 2026"}}, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift index 839a61bfe64e..7cdd1accc802 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift @@ -53,7 +53,7 @@ import Testing @Test func pinpointNoticeOnlyReachesItsVersionAndChannels() async { let payload = #""" - {"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[{ + {"visibleEntryIds":["pairing.1.0.6","connections.v1"],"announcements":[{ "id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6", "channels":["beta","internal"],"title":"What's New in 1.0.6", "features":[{"title":"Update your Mac","detail":"Requires cmux 0.64.25."}] @@ -64,7 +64,7 @@ import Testing let center = makeCenter( buildType: channel, payload: payload, - acknowledgedEntryID: "connections.v2", + acknowledgedEntryID: "pairing.1.0.6", appVersion: appVersion ) await center.refresh() @@ -82,58 +82,66 @@ import Testing @Test func announcementIsFollowedByPairingSetupOnlyOn106() async throws { let payload = #""" - {"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[{ + {"visibleEntryIds":["pairing.1.0.6","connections.v1"],"announcements":[{ "id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6", "channels":["beta","internal"],"title":"What's New in 1.0.6", "features":[{"title":"Update your Mac","detail":"Requires cmux 0.64.25."}] }]} """# - let beta = makeCenter( - buildType: .beta, - payload: payload, - acknowledgedEntryID: "connections.v1", - appVersion: "1.0.6" - ) - await beta.refresh() - #expect(beta.unseenPages.map(\.id) == ["ios-1.0.6-connections", "connections.v2"]) - let pairing = try #require(beta.unseenPages.last) - #expect(pairing.releaseLabel == "1.0.6 · September 2026") - #expect(pairing.minVersion == "1.0.6") - #expect(pairing.maxVersion == "1.0.6") - - let oldBeta = makeCenter( - buildType: .beta, - payload: payload, - acknowledgedEntryID: "connections.v1", - appVersion: "1.0.5" - ) - await oldBeta.refresh() - #expect(oldBeta.unseenPages.isEmpty) - #expect(oldBeta.visibleBinaryEntries.map(\.id) == ["connections.v1"]) + for channel in [MobileBuildType.beta, .internal] { + for marker in ["connections.v1", "connections.v2", "pairing-opt-in.v1"] { + let center = makeCenter( + buildType: channel, payload: payload, + acknowledgedEntryID: marker, appVersion: "1.0.6" + ) + await center.refresh() + #expect(center.unseenPages.map(\.id) == ["ios-1.0.6-connections", "pairing.1.0.6"]) + let pairing = try #require(center.unseenPages.last) + #expect(pairing.releaseLabel == "1.0.6 · September 2026") + center.acknowledge(center.unseenPages) + await center.refresh() + #expect(center.unseenPages.isEmpty) + } + for version in ["1.0.5", "1.0.7"] { + let center = makeCenter( + buildType: channel, payload: payload, + acknowledgedEntryID: "connections.v2", appVersion: version + ) + await center.refresh() + #expect(center.unseenPages.isEmpty) + #expect(!center.archivePages.contains { $0.id == "pairing.1.0.6" }) + } + } + for version in ["1.0.5", "1.0.7"] { + let center = makeCenter(buildType: .beta, appVersion: version) + #expect(!center.visibleBinaryEntries.contains { $0.id == "pairing.1.0.6" }) + #expect(!MobileWhatsNewCatalog().channelVisibleEntries(buildType: .beta, appVersion: version) + .contains { $0.id == "pairing.1.0.6" }) + } } @Test func pairingUpdateAppearsAfterAnOlderPageWasAcknowledged() async { let payload = #""" { - "visibleEntryIds": ["connections.v2", "connections.v1"], + "visibleEntryIds": ["pairing.1.0.6", "connections.v1"], "announcements": [] } """# - for oldMarker in ["pairing-opt-in.v1", "connections.v1"] { + for oldMarker in ["pairing-opt-in.v1", "connections.v1", "connections.v2"] { let center = makeCenter( buildType: .beta, payload: payload, acknowledgedEntryID: oldMarker ) await center.refresh() - #expect(center.unseenPages.map(\.id) == ["connections.v2"]) + #expect(center.unseenPages.map(\.id) == ["pairing.1.0.6"]) } } @Test func pairingPageFocusesOnPairingRequirement() throws { - let page = try #require(MobileWhatsNewCatalog().entry(withID: "connections.v2")) + let page = try #require(MobileWhatsNewCatalog().entry(withID: "pairing.1.0.6")) guard case .pairingSetup(let features) = page.body else { - Issue.record("connections.v2 should render the custom pairing page") + Issue.record("pairing.1.0.6 should render the custom pairing page") return } #expect(features.isEmpty) @@ -144,11 +152,11 @@ import Testing @Test func archiveKeepsBothUpdatesAfterAcknowledgingPairing() async throws { let center = makeCenter( buildType: .beta, - payload: #"{"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[]}"# + payload: #"{"visibleEntryIds":["pairing.1.0.6","connections.v1"],"announcements":[]}"# ) await center.refresh() - #expect(center.archivePages.map(\.id) == ["connections.v2", "connections.v1"]) - #expect(center.unseenPages.map(\.id) == ["connections.v2", "connections.v1"]) + #expect(center.archivePages.map(\.id) == ["pairing.1.0.6", "connections.v1"]) + #expect(center.unseenPages.map(\.id) == ["pairing.1.0.6", "connections.v1"]) let oldPage = try #require(MobileWhatsNewCatalog().entry(withID: "connections.v1")) guard case .features(let features) = oldPage.body else { Issue.record("The earlier connection update must keep its feature rows") @@ -169,7 +177,7 @@ import Testing buildType: .beta ) #expect(beta.stableVersion == "0.64.20") - #expect(beta.nightlyVersion == nil) + #expect(beta.nightlyVersion == "0.64.22-nightly.3345650013202") let official = MobileWhatsNewCatalog().macCompatibility( policy: .baked, @@ -185,7 +193,7 @@ import Testing let center = makeCenter(buildType: buildType) #expect( center.visibleBinaryEntries.map(\.id) - == MobileWhatsNewCatalog().entries.map(\.id) + == MobileWhatsNewCatalog().channelVisibleEntries(buildType: buildType, appVersion: "1.0.6").map(\.id) ) #expect(!center.unseenPages.isEmpty) } @@ -194,10 +202,10 @@ import Testing @Test func legacyPayloadWithoutChannelFieldsKeepsTeamBehavior() async { // The pre-channel server payload shape must keep decoding and must // keep meaning "team lanes only" (not "everyone"). - let payload = #"{"visibleEntryIds":["connections.v2"],"announcements":[]}"# + let payload = #"{"visibleEntryIds":["pairing.1.0.6"],"announcements":[]}"# let team = makeCenter(buildType: .beta, payload: payload) await team.refresh() - #expect(team.visibleBinaryEntries.map(\.id) == ["connections.v2"]) + #expect(team.visibleBinaryEntries.map(\.id) == ["pairing.1.0.6"]) let official = makeCenter(buildType: .prod, payload: payload) await official.refresh() @@ -209,16 +217,16 @@ import Testing let payload = #"{"visibleEntryIds":["retired.v1"],"announcements":[]}"# let center = makeCenter(buildType: .beta, payload: payload) await center.refresh() - #expect(center.visibleBinaryEntries.map(\.id) == ["connections.v2", "connections.v1"]) - #expect(center.archivePages.map(\.id) == ["connections.v2", "connections.v1"]) + #expect(center.visibleBinaryEntries.map(\.id) == ["pairing.1.0.6", "connections.v1"]) + #expect(center.archivePages.map(\.id) == ["pairing.1.0.6", "connections.v1"]) } @Test func oldServerCatalogCannotHidePairingRequirement() async { let payload = #"{"visibleEntryIds":["connections.v1"],"announcements":[]}"# let center = makeCenter(buildType: .beta, payload: payload) await center.refresh() - #expect(center.visibleBinaryEntries.map(\.id) == ["connections.v2", "connections.v1"]) - #expect(center.unseenPages.map(\.id) == ["connections.v2", "connections.v1"]) + #expect(center.visibleBinaryEntries.map(\.id) == ["pairing.1.0.6", "connections.v1"]) + #expect(center.unseenPages.map(\.id) == ["pairing.1.0.6", "connections.v1"]) } @Test func explicitEmptyServerCatalogStillHidesNativePages() async { @@ -232,15 +240,15 @@ import Testing @Test func remoteEntryChannelsOptABinaryEntryIntoOfficial() async { let payload = #""" { - "visibleEntryIds": ["connections.v2"], - "entryChannels": { "connections.v2": ["dev", "beta", "internal", "prod"] }, + "visibleEntryIds": ["pairing.1.0.6"], + "entryChannels": { "pairing.1.0.6": ["dev", "beta", "internal", "prod"] }, "announcements": [] } """# let center = makeCenter(buildType: .prod, payload: payload) await center.refresh() - #expect(center.visibleBinaryEntries.map(\.id) == ["connections.v2"]) - #expect(center.unseenPages.map(\.id) == ["connections.v2"]) + #expect(center.visibleBinaryEntries.map(\.id) == ["pairing.1.0.6"]) + #expect(center.unseenPages.map(\.id) == ["pairing.1.0.6"]) } @Test func remoteEntryChannelsCanAlsoNarrowTeamBuilds() async { @@ -248,8 +256,8 @@ import Testing // operator can retract an entry from a single lane remotely. let payload = #""" { - "visibleEntryIds": ["connections.v2"], - "entryChannels": { "connections.v2": ["prod"] }, + "visibleEntryIds": ["pairing.1.0.6"], + "entryChannels": { "pairing.1.0.6": ["prod"] }, "announcements": [] } """# diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 2ea2ea6ae347..3081f440330c 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -83709,6 +83709,65 @@ } }, "mobile.pairingOptInUpdate.releaseLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · September 2026" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · 2026年9月" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · September 2026" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · septembre 2026" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · سبتمبر 2026" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · septiembre de 2026" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · 2026 年 9 月" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · 2026 年 9 月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · 2026년 9월" + } + } + } + }, + "mobile.ios106PairingUpdate.releaseLabel": { "extractionState": "manual", "localizations": { "en": { diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index dbe262d777cf..951a2048b3ef 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -103,7 +103,7 @@ final class cmuxUITests: XCTestCase { @MainActor func testIOS106WhatsNewReleaseNotice() throws { // Snapshot of web/data/whats-new.ts; the web test checks it stays in sync. - let payload = #"{"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[{"id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6","channels":["beta","internal"],"title":"What's New in 1.0.6","releaseLabel":"1.0.6 · September 2026","features":[{"symbol":"network","title":"Updated Mac connections","detail":"This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect."},{"symbol":"arrow.down.circle","title":"Update cmux on your Mac","detail":"Requires cmux 0.64.25 or later, or NIGHTLY 0.64.25-nightly.3522337919701 or later. Enable iOS pairing in Settings > Mobile on each Mac."},{"symbol":"clock.arrow.circlepath","title":"Need to keep an older Mac build?","detail":"Use cmux BETA 1.0.5 (20260914204800) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service."}]}]}"# + let payload = #"{"visibleEntryIds":["pairing.1.0.6","connections.v2","connections.v1"],"announcements":[{"id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6","channels":["beta","internal"],"title":"What's New in 1.0.6","releaseLabel":"1.0.6 · September 2026","features":[{"symbol":"network","title":"Updated Mac connections","detail":"This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect."},{"symbol":"arrow.down.circle","title":"Update cmux on your Mac","detail":"Requires cmux 0.64.25 or later, or NIGHTLY 0.64.25-nightly.3522337919701 or later. Enable iOS pairing in Settings > Mobile on each Mac."},{"symbol":"clock.arrow.circlepath","title":"Need to keep an older Mac build?","detail":"Use cmux BETA 1.0.5 (20260914204800) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service."}]}]}"# let app = XCUIApplication() defer { app.terminate() } for (channel, version, appearance, expected) in [ @@ -122,7 +122,7 @@ final class cmuxUITests: XCTestCase { "CMUX_UITEST_WHATS_NEW_CHANNEL": channel, "CMUX_UITEST_WHATS_NEW_VERSION": version, "CMUX_UITEST_WHATS_NEW_APPEARANCE": appearance, - "CMUX_UITEST_WHATS_NEW_ACKNOWLEDGED_ENTRY_ID": "connections.v1" + "CMUX_UITEST_WHATS_NEW_ACKNOWLEDGED_ENTRY_ID": "connections.v2" ] app.launch() let title = app.staticTexts["What's New in 1.0.6"].firstMatch @@ -137,6 +137,7 @@ final class cmuxUITests: XCTestCase { format: "label CONTAINS %@", "20260914204800" )).firstMatch XCTAssertTrue(rollback.exists) + XCTAssertTrue(rollback.label.contains("BETA 1.0.5")) let screenshot = XCTAttachment(screenshot: app.screenshot()) screenshot.name = "1.0.6 notice - \(channel) - \(appearance)" screenshot.lifetime = .keepAlways @@ -147,13 +148,20 @@ final class cmuxUITests: XCTestCase { let pairingSheet = app.collectionViews["MobileWhatsNewSheet"].firstMatch XCTAssertTrue(pairingSheet.waitForExistence(timeout: 5)) XCTAssertTrue(pairingSheet.staticTexts["Action Required: Enable iOS pairing on your Mac"].waitForExistence(timeout: 5)) - XCTAssertTrue(app.staticTexts["1.0.6 · September 2026"].waitForExistence(timeout: 5)) - XCTAssertTrue(app.images["cmux Mac Settings, Mobile section, showing Enable iOS pairing."].waitForExistence(timeout: 5)) + XCTAssertTrue(app.images["MobileWhatsNewMacSettingsScreenshot"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["cmux 0.64.25 or later"].exists) + XCTAssertTrue(app.staticTexts["cmux NIGHTLY 0.64.25-nightly.3522337919701 or later"].exists) + let pairingScreenshot = XCTAttachment(screenshot: app.screenshot()) + pairingScreenshot.name = "1.0.6 pairing settings - \(channel) - \(appearance)" + pairingScreenshot.lifetime = .keepAlways + add(pairingScreenshot) continueButton.tap() + XCTAssertTrue(continueButton.waitForNonExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["MobileWhatsNewPreviewLoaded"].waitForExistence(timeout: 5)) } else { XCTAssertTrue(app.staticTexts["MobileWhatsNewPreviewLoaded"].waitForExistence(timeout: 15)) XCTAssertFalse(title.exists, "Notice must not reach \(channel) \(version)") + XCTAssertFalse(app.buttons["MobileWhatsNewContinue"].exists) } app.terminate() } diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index 4b73e45d83e8..6894136ddf4e 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -1,4 +1,10 @@ { + "mobile.ios106PairingUpdate.releaseLabel": { + "source": "1.0.6 · September 2026", + "identityLocales": { + "de": "September has the same spelling in German; the version and date are invariant." + } + }, "cloudTree.resources.provisionedCPU": { "source": "%@ vCPU · %@", "identityLocales": { diff --git a/web/data/whats-new.ts b/web/data/whats-new.ts index 1fa69b7bc235..9bf027131def 100644 --- a/web/data/whats-new.ts +++ b/web/data/whats-new.ts @@ -85,7 +85,7 @@ export const whatsNewList: WhatsNewList = { // One bespoke page now carries the Mac-side opt-in, the screenshot, the // compatibility floors, and the connection notes. The earlier standalone // pairing page is intentionally absent and can never be shown again. - visibleEntryIds: ["connections.v2", "connections.v1"], + visibleEntryIds: ["pairing.1.0.6", "connections.v2", "connections.v1"], announcements: [ { id: "ios-1.0.6-connections", diff --git a/web/tests/whats-new-route.test.ts b/web/tests/whats-new-route.test.ts index 4d21dc82f01b..f692ddf02378 100644 --- a/web/tests/whats-new-route.test.ts +++ b/web/tests/whats-new-route.test.ts @@ -31,7 +31,7 @@ describe("whats-new route channel targeting", () => { expect(response.status).toBe(200); const payload = (await response.json()) as WhatsNewList; expect(payload).toEqual(whatsNewList); - expect(payload.visibleEntryIds).toEqual(["connections.v2", "connections.v1"]); + expect(payload.visibleEntryIds).toEqual(["pairing.1.0.6", "connections.v2", "connections.v1"]); // The rejection-driven contract: no checked-in entry or announcement may // silently target the official app; reaching "prod" must be a reviewed, // explicit channel list. If this assertion fails, someone opted content From aba7280449983ab4439ce9c3ca5c3c16686d4dff Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:42:54 -0700 Subject: [PATCH 07/12] drive whats new continuation by its accessible label --- ios/cmuxUITests/cmuxUITests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 951a2048b3ef..b2d211dab57a 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -142,13 +142,13 @@ final class cmuxUITests: XCTestCase { screenshot.name = "1.0.6 notice - \(channel) - \(appearance)" screenshot.lifetime = .keepAlways add(screenshot) - let continueButton = app.buttons["MobileWhatsNewContinue"].firstMatch + let continueButton = app.buttons["Continue"].firstMatch XCTAssertTrue(continueButton.isHittable) continueButton.tap() let pairingSheet = app.collectionViews["MobileWhatsNewSheet"].firstMatch XCTAssertTrue(pairingSheet.waitForExistence(timeout: 5)) XCTAssertTrue(pairingSheet.staticTexts["Action Required: Enable iOS pairing on your Mac"].waitForExistence(timeout: 5)) - XCTAssertTrue(app.images["MobileWhatsNewMacSettingsScreenshot"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.images.matching(NSPredicate(format: "label == %@", "cmux Mac Settings, Mobile section, showing Enable iOS pairing.")).firstMatch.waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["cmux 0.64.25 or later"].exists) XCTAssertTrue(app.staticTexts["cmux NIGHTLY 0.64.25-nightly.3522337919701 or later"].exists) let pairingScreenshot = XCTAttachment(screenshot: app.screenshot()) @@ -161,7 +161,7 @@ final class cmuxUITests: XCTestCase { } else { XCTAssertTrue(app.staticTexts["MobileWhatsNewPreviewLoaded"].waitForExistence(timeout: 15)) XCTAssertFalse(title.exists, "Notice must not reach \(channel) \(version)") - XCTAssertFalse(app.buttons["MobileWhatsNewContinue"].exists) + XCTAssertFalse(app.buttons["Continue"].exists) } app.terminate() } From 11d6f3895044acfc219d333a71078fde75c8d82c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:44:12 -0700 Subject: [PATCH 08/12] fetch launch announcements before presenting pairing guidance --- .../CmuxMobileShellUI/MobileWhatsNewCenter.swift | 5 +++++ .../CmuxMobileShellUI/WorkspaceShellView.swift | 13 +++++-------- .../MobileWhatsNewChannelGateTests.swift | 11 +++++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift index 6767430674c0..10425076d391 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift @@ -51,6 +51,10 @@ public final class MobileWhatsNewCenter { /// that gates web-content pages into the one-time sheet, so an offline /// launch skips them instead of presenting an unloadable webview. private(set) var lastRefreshSucceeded = false + /// A launch presentation waits for the first attempt so cached native + /// pages cannot overtake a new remote announcement. Failure still allows + /// the cached/offline pages to appear. + private(set) var hasCompletedInitialRefresh = false public init( apiBaseURL: String?, @@ -92,6 +96,7 @@ public final class MobileWhatsNewCenter { /// failure (offline, server error, malformed payload) keeps the cached /// list: cache wins while offline. public func refresh() async { + defer { hasCompletedInitialRefresh = true } guard let requestURL else { return } do { let data = try await loader(requestURL) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 2b0067d1f19d..0173054c8c69 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -612,13 +612,9 @@ struct WorkspaceShellView: View { submitTaskComposer: submitTaskComposerFromShell ) } - // One-time What's New notice. Only users who already HAVE Computers - // see it (fresh installs learn the same things in onboarding). The - // gate first answers from the cached remote list, then refreshes the - // list and re-checks. The shell can restore straight into cached - // workspaces without ever loading the paired-Mac list (it normally - // loads on the Computers sheet or a reconnect pass), so load it here - // and re-check, otherwise the has-Computers gate never answers. + // Wait for the first remote-list attempt before presenting, so a + // cached native page cannot overtake a newer remote announcement. + // A failed fetch still permits the cached/offline pages. .onAppear { presentWhatsNewIfNeeded() } @@ -701,7 +697,8 @@ struct WorkspaceShellView: View { /// forever, late enough that a swallowed presentation (a state-restored /// sheet already occupying the presenter) never marks pages as seen. private func presentWhatsNewIfNeeded() { - guard let whatsNewCenter, !showsWhatsNewSheet else { return } + guard let whatsNewCenter, whatsNewCenter.hasCompletedInitialRefresh, + !showsWhatsNewSheet else { return } let pages = whatsNewCenter.unseenPages guard !pages.isEmpty else { return } whatsNewCandidatePages = pages diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift index 7cdd1accc802..00d1da69ad1d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift @@ -51,6 +51,15 @@ import Testing #expect(center.unseenPages.isEmpty) } + @Test func failedInitialRefreshStillAllowsOfflinePairingGuidance() async { + let center = makeCenter(buildType: .beta, acknowledgedEntryID: "connections.v2") + #expect(!center.hasCompletedInitialRefresh) + await center.refresh() + #expect(center.hasCompletedInitialRefresh) + #expect(!center.lastRefreshSucceeded) + #expect(center.unseenPages.map(\.id) == ["pairing.1.0.6"]) + } + @Test func pinpointNoticeOnlyReachesItsVersionAndChannels() async { let payload = #""" {"visibleEntryIds":["pairing.1.0.6","connections.v1"],"announcements":[{ @@ -94,7 +103,9 @@ import Testing buildType: channel, payload: payload, acknowledgedEntryID: marker, appVersion: "1.0.6" ) + #expect(!center.hasCompletedInitialRefresh) await center.refresh() + #expect(center.hasCompletedInitialRefresh) #expect(center.unseenPages.map(\.id) == ["ios-1.0.6-connections", "pairing.1.0.6"]) let pairing = try #require(center.unseenPages.last) #expect(pairing.releaseLabel == "1.0.6 · September 2026") From 3893053541a0491787baf175eb232c241701c14d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:55:24 -0700 Subject: [PATCH 09/12] keep release notice checks focused on runtime behavior --- web/tests/whats-new-route.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/tests/whats-new-route.test.ts b/web/tests/whats-new-route.test.ts index f692ddf02378..02301b2aa1ec 100644 --- a/web/tests/whats-new-route.test.ts +++ b/web/tests/whats-new-route.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; import { whatsNewList, type WhatsNewList } from "../data/whats-new"; @@ -144,8 +143,4 @@ describe("whats-new route channel targeting", () => { }); }); - test("the simulator exercises the shipped announcement payload", () => { - const source = readFileSync(new URL("../../ios/cmuxUITests/cmuxUITests.swift", import.meta.url), "utf8"); - expect(source).toContain(`let payload = #"${JSON.stringify(whatsNewList)}"#`); - }); }); From 86d61584518c9aebe7bbcc1ca7827bf3813c8549 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:58:48 -0700 Subject: [PATCH 10/12] inherit preview compatibility context inside the release sheet --- .../CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift | 1 + ios/cmuxUITests/cmuxUITests.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift index 16990cd12164..274fcfa81c09 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift @@ -54,6 +54,7 @@ public struct MobileWhatsNewPreviewView: View { allowedWebHosts: [], dismiss: { showsSheet = false } ) + .environment(center) .preferredColorScheme( ProcessInfo.processInfo.environment["CMUX_UITEST_WHATS_NEW_APPEARANCE"] == "dark" ? .dark : .light diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index b2d211dab57a..9f7002e0a799 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -102,7 +102,7 @@ final class cmuxUITests: XCTestCase { @MainActor func testIOS106WhatsNewReleaseNotice() throws { - // Snapshot of web/data/whats-new.ts; the web test checks it stays in sync. + // Snapshot of web/data/whats-new.ts used to drive the real sheet. let payload = #"{"visibleEntryIds":["pairing.1.0.6","connections.v2","connections.v1"],"announcements":[{"id":"ios-1.0.6-connections","minVersion":"1.0.6","maxVersion":"1.0.6","channels":["beta","internal"],"title":"What's New in 1.0.6","releaseLabel":"1.0.6 · September 2026","features":[{"symbol":"network","title":"Updated Mac connections","detail":"This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect."},{"symbol":"arrow.down.circle","title":"Update cmux on your Mac","detail":"Requires cmux 0.64.25 or later, or NIGHTLY 0.64.25-nightly.3522337919701 or later. Enable iOS pairing in Settings > Mobile on each Mac."},{"symbol":"clock.arrow.circlepath","title":"Need to keep an older Mac build?","detail":"Use cmux BETA 1.0.5 (20260914204800) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service."}]}]}"# let app = XCUIApplication() defer { app.terminate() } From 479b2a7660695e5059f9a3b3e506f9984839b194 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:30:34 -0700 Subject: [PATCH 11/12] test(ios): reproduce cancelled initial release notice fetch --- .../MobileWhatsNewChannelGateTests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift index 00d1da69ad1d..824ef9240b05 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift @@ -60,6 +60,61 @@ import Testing #expect(center.unseenPages.map(\.id) == ["pairing.1.0.6"]) } + @Test func cancelledInitialRefreshWaitsForTheNextCompletedAttempt() async { + let gate = RefreshGate() + let defaults = UserDefaults(suiteName: "CancelledWhatsNew-\(UUID().uuidString)")! + defaults.set("connections.v2", forKey: MobileWhatsNewCenter.markerKey) + let center = MobileWhatsNewCenter( + apiBaseURL: "https://cmux.test", appVersion: "1.0.6", + buildType: .beta, defaults: defaults, + loader: { _ in try await gate.load() } + ) + let first = Task { await center.refresh() } + await gate.waitUntilStarted() + first.cancel() + gate.finish(.failure(CancellationError())) + await first.value + #expect(!center.hasCompletedInitialRefresh) + #expect(!center.lastRefreshSucceeded) + + let second = Task { await center.refresh() } + await gate.waitUntilStarted() + #expect(!center.hasCompletedInitialRefresh) + gate.finish(.success(Data(#""" + {"visibleEntryIds":["pairing.1.0.6"],"announcements":[{ + "id":"release","minVersion":"1.0.6","maxVersion":"1.0.6", + "channels":["beta"],"title":"Release notice", + "features":[{"title":"Update your Mac","detail":"New Mac required."}] + }]} + """#.utf8))) + await second.value + #expect(center.hasCompletedInitialRefresh) + #expect(center.unseenPages.map(\.id) == ["release", "pairing.1.0.6"]) + } + + private final class RefreshGate { + private var pending: CheckedContinuation? + private var started: CheckedContinuation? + + func load() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + pending = continuation + started?.resume() + started = nil + } + } + + func waitUntilStarted() async { + guard pending == nil else { return } + await withCheckedContinuation { started = $0 } + } + + func finish(_ result: Result) { + pending?.resume(with: result) + pending = nil + } + } + @Test func pinpointNoticeOnlyReachesItsVersionAndChannels() async { let payload = #""" {"visibleEntryIds":["pairing.1.0.6","connections.v1"],"announcements":[{ From b2d3f5984226975290becdba0691d1bcfdd164ba Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:35:06 -0700 Subject: [PATCH 12/12] fix(ios): preserve release notice order on cancellation and localize announcements --- .../MobileWhatsNewCenter.swift | 14 +++- .../MobileWhatsNewRemote.swift | 26 ++++++++ .../MobileWhatsNewChannelGateTests.swift | 42 +++++++++++- web/app/api/whats-new/route.ts | 19 ++++++ web/data/whats-new-localizations.ts | 64 +++++++++++++++++++ web/data/whats-new.ts | 33 +++------- web/messages/ar.json | 10 +++ web/messages/bs.json | 10 +++ web/messages/da.json | 10 +++ web/messages/de.json | 10 +++ web/messages/en.json | 10 +++ web/messages/es.json | 10 +++ web/messages/fr.json | 10 +++ web/messages/it.json | 10 +++ web/messages/ja.json | 10 +++ web/messages/km.json | 10 +++ web/messages/ko.json | 10 +++ web/messages/no.json | 10 +++ web/messages/pl.json | 10 +++ web/messages/pt-BR.json | 10 +++ web/messages/ru.json | 10 +++ web/messages/th.json | 10 +++ web/messages/tr.json | 10 +++ web/messages/uk.json | 10 +++ web/messages/zh-CN.json | 10 +++ web/messages/zh-TW.json | 10 +++ web/tests/whats-new-route.test.ts | 25 ++++++++ 27 files changed, 398 insertions(+), 25 deletions(-) create mode 100644 web/data/whats-new-localizations.ts diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift index 10425076d391..d95735c4d6a9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift @@ -43,6 +43,7 @@ public final class MobileWhatsNewCenter { let buildType: MobileBuildType private let defaults: UserDefaults private let loader: Loader + private let preferredLanguages: [String] /// The last successfully fetched list (this launch or a previous one). /// `nil` means no list has EVER been fetched on this device. @@ -61,6 +62,7 @@ public final class MobileWhatsNewCenter { appVersion: String? = nil, buildType: MobileBuildType = .current(), defaults: UserDefaults = .standard, + preferredLanguages: [String] = Bundle.main.preferredLocalizations, loader: Loader? = nil ) { if let apiBaseURL, !apiBaseURL.isEmpty { @@ -73,6 +75,7 @@ public final class MobileWhatsNewCenter { ?? "0" self.buildType = buildType self.defaults = defaults + self.preferredLanguages = preferredLanguages self.loader = loader ?? mobileRemoteJSONLoader if let cached = defaults.data(forKey: environmentCacheKey), let list = try? JSONDecoder().decode(MobileWhatsNewRemoteList.self, from: cached) { @@ -96,15 +99,23 @@ public final class MobileWhatsNewCenter { /// failure (offline, server error, malformed payload) keeps the cached /// list: cache wins while offline. public func refresh() async { - defer { hasCompletedInitialRefresh = true } + var cancelled = false + defer { + if !cancelled && !Task.isCancelled { + hasCompletedInitialRefresh = true + } + } guard let requestURL else { return } do { let data = try await loader(requestURL) + try Task.checkCancellation() let list = try JSONDecoder().decode(MobileWhatsNewRemoteList.self, from: data) remoteList = list lastRefreshSucceeded = true defaults.set(data, forKey: environmentCacheKey) pruneAcknowledgedAnnouncements(against: list) + } catch where error is CancellationError || (error as? URLError)?.code == .cancelled { + cancelled = true } catch { // Keep the cached list; no cache ever fetched means binary // entries stay visible (fail-open to binary truth). @@ -265,6 +276,7 @@ public final class MobileWhatsNewCenter { /// announcement's own fallback content (webpage, then inline feature /// rows) renders. private func page(for announcement: MobileWhatsNewRemoteAnnouncement) -> MobileWhatsNewPage? { + let announcement = announcement.localized(for: preferredLanguages) guard let title = announcement.title, !title.isEmpty else { return nil } if let web = allowlistedWebURL(announcement.webUrl) { return MobileWhatsNewPage( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift index e7547034ebdc..92b7ddef998c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift @@ -75,6 +75,32 @@ struct MobileWhatsNewRemoteAnnouncement: Codable, Identifiable { var detail: String } + struct LocalizedContent: Codable { + var title: String + var releaseLabel: String? + var features: [Feature] + } + + /// Complete translated content. Base fields remain an English fallback + /// for older clients and unsupported languages; identity and targeting + /// never vary by language. + var localizations: [String: LocalizedContent]? = nil + + func localized(for preferredLanguages: [String]) -> Self { + guard let localizations else { return self } + let languages = Bundle.preferredLocalizations( + from: localizations.keys.sorted(), + forPreferences: preferredLanguages + ["en"] + ) + guard let language = languages.first, + let content = localizations[language] else { return self } + var result = self + result.title = content.title + result.releaseLabel = content.releaseLabel + result.features = content.features + return result + } + var id: String var minVersion: String var maxVersion: String diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift index 824ef9240b05..7830a0b3b953 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift @@ -18,7 +18,8 @@ import Testing buildType: MobileBuildType, payload: String? = nil, acknowledgedEntryID: String? = nil, - appVersion: String = "1.0.6" + appVersion: String = "1.0.6", + preferredLanguages: [String] = ["en"] ) -> MobileWhatsNewCenter { let suiteName = "MobileWhatsNewChannelGateTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -34,6 +35,7 @@ import Testing appVersion: appVersion, buildType: buildType, defaults: defaults, + preferredLanguages: preferredLanguages, loader: { _ in guard let payload else { throw URLError(.notConnectedToInternet) } return Data(payload.utf8) @@ -92,6 +94,7 @@ import Testing #expect(center.unseenPages.map(\.id) == ["release", "pairing.1.0.6"]) } + @MainActor private final class RefreshGate { private var pending: CheckedContinuation? private var started: CheckedContinuation? @@ -115,6 +118,43 @@ import Testing } } + @Test(arguments: [ + (["ja-JP"], "新機能", "更新してください"), + (["zh-Hant-TW"], "更新內容", "請更新"), + (["xx"], "Release notice", "Update your Mac") + ]) + func announcementUsesAppLanguageAndRetainsEnglishFallback( + languages: [String], title: String, detail: String + ) async throws { + let center = makeCenter( + buildType: .beta, + payload: #""" + {"visibleEntryIds":["pairing.1.0.6"],"announcements":[{ + "id":"release","minVersion":"1.0.6","maxVersion":"1.0.6", + "channels":["beta"],"title":"Release notice", + "features":[{"title":"Mac","detail":"Update your Mac"}], + "localizations":{ + "en":{"title":"Release notice","features":[{"title":"Mac","detail":"Update your Mac"}]}, + "ja":{"title":"新機能","features":[{"title":"Mac","detail":"更新してください"}]}, + "zh-TW":{"title":"更新內容","features":[{"title":"Mac","detail":"請更新"}]} + } + }]} + """#, + preferredLanguages: languages + ) + await center.refresh() + let page = try #require(center.announcementPages.first) + #expect(page.id == "release") + #expect(page.title == title) + guard case .features(let features) = page.body else { + Issue.record("Expected translated feature rows") + return + } + #expect(features.first?.detail == detail) + center.acknowledge([page]) + #expect(!center.unseenPages.contains { $0.id == "release" }) + } + @Test func pinpointNoticeOnlyReachesItsVersionAndChannels() async { let payload = #""" {"visibleEntryIds":["pairing.1.0.6","connections.v1"],"announcements":[{ diff --git a/web/app/api/whats-new/route.ts b/web/app/api/whats-new/route.ts index a9690910b30b..70d562e41202 100644 --- a/web/app/api/whats-new/route.ts +++ b/web/app/api/whats-new/route.ts @@ -98,6 +98,8 @@ function validateAnnouncement(announcement: WhatsNewAnnouncement, path: string): channelList(announcement.channels, `${path}.channels`); } + validateLocalizations(announcement, path); + const hasNative = announcement.nativeEntryId !== undefined; const hasWeb = announcement.webUrl !== undefined; const hasFeatures = (announcement.features?.length ?? 0) > 0; @@ -130,6 +132,23 @@ function validateAnnouncement(announcement: WhatsNewAnnouncement, path: string): } } +function validateLocalizations(announcement: WhatsNewAnnouncement, path: string): void { + for (const [locale, content] of Object.entries(announcement.localizations ?? {})) { + const localizedPath = `${path}.localizations[${locale}]`; + nonemptyString(locale, `${path}.localizations locale`); + nonemptyString(content.title, `${localizedPath}.title`); + if (content.releaseLabel !== undefined) { + nonemptyString(content.releaseLabel, `${localizedPath}.releaseLabel`); + } + if (!content.features?.length) { + throw new Error(`${localizedPath}.features must not be empty`); + } + for (const [index, feature] of content.features.entries()) { + validateFeature(feature, `${localizedPath}.features[${index}]`); + } + } +} + function validateFeature(feature: WhatsNewAnnouncementFeature, path: string): void { nonemptyString(feature.title, `${path}.title`); nonemptyString(feature.detail, `${path}.detail`); diff --git a/web/data/whats-new-localizations.ts b/web/data/whats-new-localizations.ts new file mode 100644 index 000000000000..e8a2af6063de --- /dev/null +++ b/web/data/whats-new-localizations.ts @@ -0,0 +1,64 @@ +import en from "../messages/en.json"; +import de from "../messages/de.json"; +import fr from "../messages/fr.json"; +import es from "../messages/es.json"; +import ja from "../messages/ja.json"; +import ko from "../messages/ko.json"; +import zhCN from "../messages/zh-CN.json"; +import zhTW from "../messages/zh-TW.json"; +import ar from "../messages/ar.json"; +import it from "../messages/it.json"; +import da from "../messages/da.json"; +import pl from "../messages/pl.json"; +import ru from "../messages/ru.json"; +import bs from "../messages/bs.json"; +import no from "../messages/no.json"; +import ptBR from "../messages/pt-BR.json"; +import th from "../messages/th.json"; +import tr from "../messages/tr.json"; +import km from "../messages/km.json"; +import uk from "../messages/uk.json"; +import { ios106MacRequirement } from "./mobile-mac-compat"; +import type { WhatsNewAnnouncementContent } from "./whats-new"; + +const messages = { + "en": en.MobileRelease106, + "de": de.MobileRelease106, + "fr": fr.MobileRelease106, + "es": es.MobileRelease106, + "ja": ja.MobileRelease106, + "ko": ko.MobileRelease106, + "zh-CN": zhCN.MobileRelease106, + "zh-TW": zhTW.MobileRelease106, + "ar": ar.MobileRelease106, + "it": it.MobileRelease106, + "da": da.MobileRelease106, + "pl": pl.MobileRelease106, + "ru": ru.MobileRelease106, + "bs": bs.MobileRelease106, + "no": no.MobileRelease106, + "pt-BR": ptBR.MobileRelease106, + "th": th.MobileRelease106, + "tr": tr.MobileRelease106, + "km": km.MobileRelease106, + "uk": uk.MobileRelease106 +}; + +function format(detail: string): string { + return detail + .replaceAll("{stableVersion}", ios106MacRequirement.stableMinVersion) + .replaceAll("{nightlyVersion}", `${ios106MacRequirement.nightly.minBaseVersion}-nightly.${ios106MacRequirement.nightly.minBuild}`) + .replaceAll("{rollbackBuild}", "20260914204800"); +} + +export const ios106Localizations: Record = Object.fromEntries( + Object.entries(messages).map(([locale, copy]) => [locale, { + title: copy.title, + releaseLabel: copy.releaseLabel, + features: [ + { symbol: "network", title: copy.connectionTitle, detail: copy.connectionDetail }, + { symbol: "arrow.down.circle", title: copy.updateTitle, detail: format(copy.updateDetail) }, + { symbol: "clock.arrow.circlepath", title: copy.rollbackTitle, detail: format(copy.rollbackDetail) }, + ], + }]), +); diff --git a/web/data/whats-new.ts b/web/data/whats-new.ts index 9bf027131def..48722369a709 100644 --- a/web/data/whats-new.ts +++ b/web/data/whats-new.ts @@ -1,4 +1,4 @@ -import { ios106MacRequirement } from "./mobile-mac-compat"; +import { ios106Localizations } from "./whats-new-localizations"; /** * The remote What's New list served by GET /api/whats-new. @@ -52,7 +52,14 @@ export interface WhatsNewAnnouncementFeature { detail: string; } +export interface WhatsNewAnnouncementContent { + title: string; + releaseLabel?: string; + features: WhatsNewAnnouncementFeature[]; +} + export interface WhatsNewAnnouncement { + localizations?: Record; id: string; minVersion: string; maxVersion: string; @@ -92,28 +99,8 @@ export const whatsNewList: WhatsNewList = { minVersion: "1.0.6", maxVersion: "1.0.6", channels: ["beta", "internal"], - title: "What's New in 1.0.6", - releaseLabel: "1.0.6 · September 2026", - features: [ - { - symbol: "network", - title: "Updated Mac connections", - detail: - "This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect.", - }, - { - symbol: "arrow.down.circle", - title: "Update cmux on your Mac", - detail: - `Requires cmux ${ios106MacRequirement.stableMinVersion} or later, or NIGHTLY ${ios106MacRequirement.nightly.minBaseVersion}-nightly.${ios106MacRequirement.nightly.minBuild} or later. Enable iOS pairing in Settings > Mobile on each Mac.`, - }, - { - symbol: "clock.arrow.circlepath", - title: "Need to keep an older Mac build?", - detail: - "Use cmux BETA 1.0.5 (20260914204800) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service.", - }, - ], + ...ios106Localizations.en, + localizations: ios106Localizations, }, ], }; diff --git a/web/messages/ar.json b/web/messages/ar.json index 238d099960f8..2bcad5a903c9 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -4024,5 +4024,15 @@ "organization": "المؤسسة", "personalAccount": "حساب شخصي", "switchAccountButton": "استخدام حساب مختلف" + }, + "MobileRelease106": { + "title": "الجديد في 1.0.6", + "releaseLabel": "1.0.6 · سبتمبر 2026", + "connectionTitle": "تحديث اتصالات Mac", + "connectionDetail": "يستخدم هذا التحديث خدمة الاتصال الجديدة ونقل Iroh. تحتاج إصدارات Mac القديمة إلى تحديث كي تتصل.", + "updateTitle": "حدّث cmux على Mac", + "updateDetail": "يتطلب cmux {stableVersion} أو أحدث، أو NIGHTLY {nightlyVersion} أو أحدث. فعّل إقران iOS في الإعدادات > الهاتف المحمول على كل Mac.", + "rollbackTitle": "هل تحتاج إلى الاحتفاظ بإصدار Mac قديم؟", + "rollbackDetail": "استخدم cmux BETA 1.0.5 ({rollbackBuild}) من TestFlight > الإصدارات السابقة ما دام متاحًا. اختر هذا الإصدار بالتحديد، لأن الإصدارات اللاحقة تستخدم خدمة اتصال أحدث." } } diff --git a/web/messages/bs.json b/web/messages/bs.json index 1667a99c4736..6c511e6755ec 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -4027,5 +4027,15 @@ "organization": "Organizacija", "personalAccount": "lični račun", "switchAccountButton": "Koristi drugi račun" + }, + "MobileRelease106": { + "title": "Novosti u verziji 1.0.6", + "releaseLabel": "1.0.6 · Septembar 2026", + "connectionTitle": "Ažurirane veze s Mac računarima", + "connectionDetail": "Ovo ažuriranje koristi novu uslugu povezivanja i Iroh prijenos. Starije Mac verzije treba ažurirati da bi se povezale.", + "updateTitle": "Ažurirajte cmux na Mac računaru", + "updateDetail": "Potreban je cmux {stableVersion} ili noviji, ili NIGHTLY {nightlyVersion} ili noviji. Omogućite iOS uparivanje u Postavke > Mobilni uređaji na svakom Mac računaru.", + "rollbackTitle": "Trebate zadržati stariju Mac verziju?", + "rollbackDetail": "Koristite cmux BETA 1.0.5 ({rollbackBuild}) iz TestFlight > Prethodne verzije dok je dostupna. Odaberite upravo tu verziju jer novije koriste noviju uslugu povezivanja." } } diff --git a/web/messages/da.json b/web/messages/da.json index 970737acc742..af333bd3e9b0 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -4024,5 +4024,15 @@ "organization": "Organisation", "personalAccount": "personlig konto", "switchAccountButton": "Brug en anden konto" + }, + "MobileRelease106": { + "title": "Nyt i 1.0.6", + "releaseLabel": "1.0.6 · September 2026", + "connectionTitle": "Opdaterede Mac-forbindelser", + "connectionDetail": "Denne opdatering bruger den nye forbindelsestjeneste og Iroh-transport. Ældre Mac-builds skal opdateres for at oprette forbindelse.", + "updateTitle": "Opdater cmux på din Mac", + "updateDetail": "Kræver cmux {stableVersion} eller nyere eller NIGHTLY {nightlyVersion} eller nyere. Slå iOS-parring til i Indstillinger > Mobil på hver Mac.", + "rollbackTitle": "Har du brug for at beholde et ældre Mac-build?", + "rollbackDetail": "Brug cmux BETA 1.0.5 ({rollbackBuild}) fra TestFlight > Tidligere builds, så længe det er tilgængeligt. Vælg netop dette build, da senere builds bruger en nyere forbindelsestjeneste." } } diff --git a/web/messages/de.json b/web/messages/de.json index 54ae8ee2929a..2bf716c5ac22 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -4027,5 +4027,15 @@ "organization": "Organisation", "personalAccount": "persönliches Konto", "switchAccountButton": "Anderes Konto verwenden" + }, + "MobileRelease106": { + "title": "Neu in 1.0.6", + "releaseLabel": "1.0.6 · September 2026", + "connectionTitle": "Aktualisierte Mac-Verbindungen", + "connectionDetail": "Dieses Update verwendet den neuen Verbindungsdienst und Iroh. Ältere Mac-Builds müssen aktualisiert werden, um eine Verbindung herzustellen.", + "updateTitle": "cmux auf dem Mac aktualisieren", + "updateDetail": "Erfordert cmux {stableVersion} oder neuer bzw. NIGHTLY {nightlyVersion} oder neuer. Aktiviere die iOS-Kopplung auf jedem Mac unter Einstellungen > Mobilgeräte.", + "rollbackTitle": "Du möchtest einen älteren Mac-Build behalten?", + "rollbackDetail": "Verwende cmux BETA 1.0.5 ({rollbackBuild}) unter TestFlight > Frühere Builds, solange verfügbar. Wähle genau diesen Build, da spätere Builds einen neueren Verbindungsdienst verwenden." } } diff --git a/web/messages/en.json b/web/messages/en.json index 31174665f263..af9785f3471d 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -5830,5 +5830,15 @@ "organization": "Organization", "personalAccount": "personal account", "switchAccountButton": "Use a different account" + }, + "MobileRelease106": { + "title": "What's New in 1.0.6", + "releaseLabel": "1.0.6 · September 2026", + "connectionTitle": "Updated Mac connections", + "connectionDetail": "This update uses the new connection service and Iroh transport. Older Mac builds need an update to connect.", + "updateTitle": "Update cmux on your Mac", + "updateDetail": "Requires cmux {stableVersion} or later, or NIGHTLY {nightlyVersion} or later. Enable iOS pairing in Settings > Mobile on each Mac.", + "rollbackTitle": "Need to keep an older Mac build?", + "rollbackDetail": "Use cmux BETA 1.0.5 ({rollbackBuild}) from TestFlight > Previous Builds while it remains available. Choose that exact build, since later builds use a newer connection service." } } diff --git a/web/messages/es.json b/web/messages/es.json index e9db66686241..2fb087875975 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -4026,5 +4026,15 @@ "organization": "Organización", "personalAccount": "cuenta personal", "switchAccountButton": "Usar otra cuenta" + }, + "MobileRelease106": { + "title": "Novedades de 1.0.6", + "releaseLabel": "1.0.6 · Septiembre de 2026", + "connectionTitle": "Conexiones con Mac actualizadas", + "connectionDetail": "Esta actualización usa el nuevo servicio de conexión y el transporte Iroh. Las versiones antiguas de Mac necesitan actualizarse para conectarse.", + "updateTitle": "Actualiza cmux en tu Mac", + "updateDetail": "Requiere cmux {stableVersion} o posterior, o NIGHTLY {nightlyVersion} o posterior. Activa el enlace con iOS en Ajustes > Móvil en cada Mac.", + "rollbackTitle": "¿Necesitas mantener una versión anterior en el Mac?", + "rollbackDetail": "Usa cmux BETA 1.0.5 ({rollbackBuild}) desde TestFlight > Compilaciones anteriores mientras siga disponible. Elige esa compilación exacta, ya que las posteriores usan un servicio de conexión más reciente." } } diff --git a/web/messages/fr.json b/web/messages/fr.json index 1d4eac64962d..f9012bc8d26e 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -4029,5 +4029,15 @@ "organization": "Organisation", "personalAccount": "compte personnel", "switchAccountButton": "Utiliser un autre compte" + }, + "MobileRelease106": { + "title": "Nouveautés de la version 1.0.6", + "releaseLabel": "1.0.6 · Septembre 2026", + "connectionTitle": "Connexions Mac mises à jour", + "connectionDetail": "Cette mise à jour utilise le nouveau service de connexion et le transport Iroh. Les anciennes versions Mac doivent être mises à jour pour se connecter.", + "updateTitle": "Mettez à jour cmux sur votre Mac", + "updateDetail": "Nécessite cmux {stableVersion} ou version ultérieure, ou NIGHTLY {nightlyVersion} ou version ultérieure. Activez le jumelage iOS dans Réglages > Mobile sur chaque Mac.", + "rollbackTitle": "Vous devez conserver une ancienne version Mac ?", + "rollbackDetail": "Utilisez cmux BETA 1.0.5 ({rollbackBuild}) dans TestFlight > Versions précédentes tant qu’elle reste disponible. Choisissez exactement cette version, car les suivantes utilisent un service de connexion plus récent." } } diff --git a/web/messages/it.json b/web/messages/it.json index bd930b601f7c..de20be1b3e81 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -4027,5 +4027,15 @@ "organization": "Organizzazione", "personalAccount": "account personale", "switchAccountButton": "Usa un altro account" + }, + "MobileRelease106": { + "title": "Novità di 1.0.6", + "releaseLabel": "1.0.6 · Settembre 2026", + "connectionTitle": "Connessioni Mac aggiornate", + "connectionDetail": "Questo aggiornamento usa il nuovo servizio di connessione e il trasporto Iroh. Le build Mac precedenti richiedono un aggiornamento per connettersi.", + "updateTitle": "Aggiorna cmux sul Mac", + "updateDetail": "Richiede cmux {stableVersion} o successivo, oppure NIGHTLY {nightlyVersion} o successivo. Abilita l’abbinamento iOS in Impostazioni > Mobile su ogni Mac.", + "rollbackTitle": "Devi mantenere una vecchia build Mac?", + "rollbackDetail": "Usa cmux BETA 1.0.5 ({rollbackBuild}) da TestFlight > Build precedenti finché è disponibile. Scegli esattamente questa build, perché quelle successive usano un servizio di connessione più recente." } } diff --git a/web/messages/ja.json b/web/messages/ja.json index 8224ba5493ff..01b87fa6218f 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -5740,5 +5740,15 @@ "organization": "組織", "personalAccount": "個人アカウント", "switchAccountButton": "別のアカウントを使用" + }, + "MobileRelease106": { + "title": "1.0.6 の新機能", + "releaseLabel": "1.0.6 · 2026年9月", + "connectionTitle": "Mac 接続の更新", + "connectionDetail": "このアップデートでは、新しい接続サービスと Iroh 通信を使用します。古い Mac ビルドは、接続するためにアップデートが必要です。", + "updateTitle": "Mac の cmux をアップデート", + "updateDetail": "cmux {stableVersion} 以降、または NIGHTLY {nightlyVersion} 以降が必要です。各 Mac の「設定」>「モバイル」で iOS ペアリングを有効にしてください。", + "rollbackTitle": "古い Mac ビルドを使い続ける場合", + "rollbackDetail": "利用可能な間は、TestFlight の「以前のビルド」から cmux BETA 1.0.5 ({rollbackBuild}) を使用してください。それ以降のビルドは新しい接続サービスを使用するため、このビルドを選んでください。" } } diff --git a/web/messages/km.json b/web/messages/km.json index f2e894ef39a1..413230f76f77 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -4025,5 +4025,15 @@ "organization": "ស្ថាប័ន", "personalAccount": "គណនីផ្ទាល់ខ្លួន", "switchAccountButton": "ប្រើគណនីផ្សេងទៀត" + }, + "MobileRelease106": { + "title": "អ្វីដែលថ្មីក្នុង 1.0.6", + "releaseLabel": "1.0.6 · ខែកញ្ញា 2026", + "connectionTitle": "ការតភ្ជាប់ Mac ត្រូវបានធ្វើបច្ចុប្បន្នភាព", + "connectionDetail": "ការអាប់ដេតនេះប្រើសេវាតភ្ជាប់ថ្មី និងការបញ្ជូន Iroh។ កំណែ Mac ចាស់ត្រូវអាប់ដេតដើម្បីតភ្ជាប់។", + "updateTitle": "អាប់ដេត cmux នៅលើ Mac របស់អ្នក", + "updateDetail": "ត្រូវការ cmux {stableVersion} ឬថ្មីជាងនេះ ឬ NIGHTLY {nightlyVersion} ឬថ្មីជាងនេះ។ បើកការផ្គូផ្គង iOS ក្នុង ការកំណត់ > ទូរសព្ទ នៅលើ Mac នីមួយៗ។", + "rollbackTitle": "ត្រូវការរក្សាកំណែ Mac ចាស់មែនទេ?", + "rollbackDetail": "ប្រើ cmux BETA 1.0.5 ({rollbackBuild}) ពី TestFlight > កំណែមុន ខណៈដែលវានៅអាចប្រើបាន។ ជ្រើសរើសកំណែនេះឱ្យបានច្បាស់ ព្រោះកំណែក្រោយប្រើសេវាតភ្ជាប់ថ្មីជាង។" } } diff --git a/web/messages/ko.json b/web/messages/ko.json index 970540e942f4..57f8a584f690 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -4024,5 +4024,15 @@ "organization": "조직", "personalAccount": "개인 계정", "switchAccountButton": "다른 계정 사용" + }, + "MobileRelease106": { + "title": "1.0.6의 새로운 기능", + "releaseLabel": "1.0.6 · 2026년 9월", + "connectionTitle": "Mac 연결 업데이트", + "connectionDetail": "이 업데이트는 새로운 연결 서비스와 Iroh 전송을 사용합니다. 이전 Mac 빌드는 연결하려면 업데이트해야 합니다.", + "updateTitle": "Mac에서 cmux 업데이트", + "updateDetail": "cmux {stableVersion} 이상 또는 NIGHTLY {nightlyVersion} 이상이 필요합니다. 각 Mac의 설정 > 모바일에서 iOS 페어링을 활성화하세요.", + "rollbackTitle": "이전 Mac 빌드를 계속 사용해야 하나요?", + "rollbackDetail": "제공되는 동안 TestFlight > 이전 빌드에서 cmux BETA 1.0.5 ({rollbackBuild})를 사용하세요. 이후 빌드는 새로운 연결 서비스를 사용하므로 이 빌드를 정확히 선택하세요." } } diff --git a/web/messages/no.json b/web/messages/no.json index c21a346c8c08..56c05f44f390 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -4024,5 +4024,15 @@ "organization": "Organisasjon", "personalAccount": "personlig konto", "switchAccountButton": "Bruk en annen konto" + }, + "MobileRelease106": { + "title": "Nytt i 1.0.6", + "releaseLabel": "1.0.6 · September 2026", + "connectionTitle": "Oppdaterte Mac-tilkoblinger", + "connectionDetail": "Denne oppdateringen bruker den nye tilkoblingstjenesten og Iroh-transport. Eldre Mac-bygg må oppdateres for å koble til.", + "updateTitle": "Oppdater cmux på Macen", + "updateDetail": "Krever cmux {stableVersion} eller nyere, eller NIGHTLY {nightlyVersion} eller nyere. Slå på iOS-sammenkobling i Innstillinger > Mobil på hver Mac.", + "rollbackTitle": "Må du beholde et eldre Mac-bygg?", + "rollbackDetail": "Bruk cmux BETA 1.0.5 ({rollbackBuild}) fra TestFlight > Tidligere bygg så lenge det er tilgjengelig. Velg akkurat dette bygget, siden senere bygg bruker en nyere tilkoblingstjeneste." } } diff --git a/web/messages/pl.json b/web/messages/pl.json index 5ed5c5e3e1fc..04ce0c7166f5 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -4027,5 +4027,15 @@ "organization": "Organizacja", "personalAccount": "konto osobiste", "switchAccountButton": "Użyj innego konta" + }, + "MobileRelease106": { + "title": "Co nowego w 1.0.6", + "releaseLabel": "1.0.6 · Wrzesień 2026", + "connectionTitle": "Zaktualizowane połączenia z Makiem", + "connectionDetail": "Ta aktualizacja korzysta z nowej usługi połączeń i transportu Iroh. Starsze kompilacje na Maca wymagają aktualizacji, aby się połączyć.", + "updateTitle": "Zaktualizuj cmux na Macu", + "updateDetail": "Wymaga cmux {stableVersion} lub nowszego albo NIGHTLY {nightlyVersion} lub nowszego. Włącz parowanie iOS w Ustawienia > Urządzenia mobilne na każdym Macu.", + "rollbackTitle": "Musisz zachować starszą kompilację na Macu?", + "rollbackDetail": "Użyj cmux BETA 1.0.5 ({rollbackBuild}) z TestFlight > Poprzednie kompilacje, dopóki jest dostępna. Wybierz dokładnie tę kompilację, ponieważ późniejsze korzystają z nowszej usługi połączeń." } } diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index a703329c5883..5df0c5a6b0ca 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -4027,5 +4027,15 @@ "organization": "Organização", "personalAccount": "conta pessoal", "switchAccountButton": "Usar outra conta" + }, + "MobileRelease106": { + "title": "Novidades da versão 1.0.6", + "releaseLabel": "1.0.6 · Setembro de 2026", + "connectionTitle": "Conexões com o Mac atualizadas", + "connectionDetail": "Esta atualização usa o novo serviço de conexão e o transporte Iroh. As compilações antigas para Mac precisam ser atualizadas para se conectar.", + "updateTitle": "Atualize o cmux no Mac", + "updateDetail": "Requer cmux {stableVersion} ou posterior, ou NIGHTLY {nightlyVersion} ou posterior. Ative o emparelhamento com iOS em Ajustes > Celular em cada Mac.", + "rollbackTitle": "Precisa manter uma compilação antiga no Mac?", + "rollbackDetail": "Use cmux BETA 1.0.5 ({rollbackBuild}) em TestFlight > Compilações anteriores enquanto estiver disponível. Escolha exatamente essa compilação, pois as posteriores usam um serviço de conexão mais recente." } } diff --git a/web/messages/ru.json b/web/messages/ru.json index e0c50ae14071..07520b9ea75c 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -4027,5 +4027,15 @@ "organization": "Организация", "personalAccount": "личный аккаунт", "switchAccountButton": "Использовать другой аккаунт" + }, + "MobileRelease106": { + "title": "Что нового в 1.0.6", + "releaseLabel": "1.0.6 · Сентябрь 2026", + "connectionTitle": "Обновлены подключения к Mac", + "connectionDetail": "Обновление использует новый сервис подключения и транспорт Iroh. Старые сборки для Mac нужно обновить, чтобы подключаться.", + "updateTitle": "Обновите cmux на Mac", + "updateDetail": "Требуется cmux {stableVersion} или новее либо NIGHTLY {nightlyVersion} или новее. Включите сопряжение с iOS в Настройки > Мобильные устройства на каждом Mac.", + "rollbackTitle": "Нужно сохранить старую сборку для Mac?", + "rollbackDetail": "Используйте cmux BETA 1.0.5 ({rollbackBuild}) из TestFlight > Предыдущие сборки, пока она доступна. Выберите именно эту сборку: более поздние используют новый сервис подключения." } } diff --git a/web/messages/th.json b/web/messages/th.json index be271bb9bc29..7d0b360dbdd3 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -4032,5 +4032,15 @@ "organization": "องค์กร", "personalAccount": "บัญชีส่วนตัว", "switchAccountButton": "ใช้บัญชีอื่น" + }, + "MobileRelease106": { + "title": "มีอะไรใหม่ใน 1.0.6", + "releaseLabel": "1.0.6 · กันยายน 2026", + "connectionTitle": "อัปเดตการเชื่อมต่อ Mac", + "connectionDetail": "การอัปเดตนี้ใช้บริการเชื่อมต่อใหม่และการรับส่งข้อมูล Iroh บิลด์ Mac รุ่นเก่าต้องอัปเดตจึงจะเชื่อมต่อได้", + "updateTitle": "อัปเดต cmux บน Mac", + "updateDetail": "ต้องใช้ cmux {stableVersion} ขึ้นไป หรือ NIGHTLY {nightlyVersion} ขึ้นไป เปิดใช้การจับคู่ iOS ในการตั้งค่า > มือถือบน Mac แต่ละเครื่อง", + "rollbackTitle": "ต้องการใช้บิลด์ Mac รุ่นเก่าต่อไปหรือไม่", + "rollbackDetail": "ใช้ cmux BETA 1.0.5 ({rollbackBuild}) จาก TestFlight > บิลด์ก่อนหน้าในขณะที่ยังมีให้ใช้ เลือกบิลด์นี้โดยตรง เพราะบิลด์ที่ใหม่กว่าจะใช้บริการเชื่อมต่อที่ใหม่กว่า" } } diff --git a/web/messages/tr.json b/web/messages/tr.json index c61616c5fb2a..f1f77a409f39 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -4025,5 +4025,15 @@ "organization": "Kuruluş", "personalAccount": "kişisel hesap", "switchAccountButton": "Farklı bir hesap kullan" + }, + "MobileRelease106": { + "title": "1.0.6 sürümündeki yenilikler", + "releaseLabel": "1.0.6 · Eylül 2026", + "connectionTitle": "Mac bağlantıları güncellendi", + "connectionDetail": "Bu güncelleme yeni bağlantı hizmetini ve Iroh aktarımını kullanır. Eski Mac derlemelerinin bağlanabilmesi için güncellenmesi gerekir.", + "updateTitle": "Mac’inizde cmux’u güncelleyin", + "updateDetail": "cmux {stableVersion} veya üstü ya da NIGHTLY {nightlyVersion} veya üstü gerekir. Her Mac’te Ayarlar > Mobil bölümünden iOS eşleştirmesini etkinleştirin.", + "rollbackTitle": "Eski bir Mac derlemesini kullanmanız mı gerekiyor?", + "rollbackDetail": "Kullanılabilir olduğu sürece TestFlight > Önceki Derlemeler’den cmux BETA 1.0.5 ({rollbackBuild}) sürümünü kullanın. Sonraki derlemeler daha yeni bir bağlantı hizmeti kullandığı için tam olarak bu derlemeyi seçin." } } diff --git a/web/messages/uk.json b/web/messages/uk.json index bb46460e97ec..1b6906467670 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -4101,5 +4101,15 @@ "organization": "Організація", "personalAccount": "особистий обліковий запис", "switchAccountButton": "Використати інший обліковий запис" + }, + "MobileRelease106": { + "title": "Що нового у 1.0.6", + "releaseLabel": "1.0.6 · Вересень 2026", + "connectionTitle": "Оновлені підключення до Mac", + "connectionDetail": "Це оновлення використовує новий сервіс підключення та транспорт Iroh. Старі збірки для Mac потрібно оновити, щоб підключатися.", + "updateTitle": "Оновіть cmux на Mac", + "updateDetail": "Потрібен cmux {stableVersion} або новіший чи NIGHTLY {nightlyVersion} або новіший. Увімкніть спарювання з iOS у Параметри > Мобільні пристрої на кожному Mac.", + "rollbackTitle": "Потрібно зберегти стару збірку для Mac?", + "rollbackDetail": "Використовуйте cmux BETA 1.0.5 ({rollbackBuild}) із TestFlight > Попередні збірки, доки вона доступна. Виберіть саме цю збірку, оскільки пізніші використовують новіший сервіс підключення." } } diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index e1fd8fe3b57d..79416f6510f5 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -4027,5 +4027,15 @@ "organization": "组织", "personalAccount": "个人账户", "switchAccountButton": "使用其他账号" + }, + "MobileRelease106": { + "title": "1.0.6 更新内容", + "releaseLabel": "1.0.6 · 2026年9月", + "connectionTitle": "Mac 连接已更新", + "connectionDetail": "此更新使用新的连接服务和 Iroh 传输。较旧的 Mac 构建版本需要更新才能连接。", + "updateTitle": "更新 Mac 上的 cmux", + "updateDetail": "需要 cmux {stableVersion} 或更高版本,或 NIGHTLY {nightlyVersion} 或更高版本。请在每台 Mac 的“设置”>“移动设备”中启用 iOS 配对。", + "rollbackTitle": "需要保留较旧的 Mac 构建版本?", + "rollbackDetail": "在仍可用时,从 TestFlight > 先前的构建版本中使用 cmux BETA 1.0.5 ({rollbackBuild})。请选择这个确切的构建版本,因为后续构建使用更新的连接服务。" } } diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index d176daa5e8d2..9a6c4e345ce8 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -4026,5 +4026,15 @@ "organization": "組織", "personalAccount": "個人帳戶", "switchAccountButton": "使用其他帳號" + }, + "MobileRelease106": { + "title": "1.0.6 更新內容", + "releaseLabel": "1.0.6 · 2026年9月", + "connectionTitle": "Mac 連線已更新", + "connectionDetail": "此更新使用新的連線服務與 Iroh 傳輸。較舊的 Mac 組建需要更新才能連線。", + "updateTitle": "更新 Mac 上的 cmux", + "updateDetail": "需要 cmux {stableVersion} 或更新版本,或 NIGHTLY {nightlyVersion} 或更新版本。請在每台 Mac 的「設定」>「行動裝置」中啟用 iOS 配對。", + "rollbackTitle": "需要保留較舊的 Mac 組建?", + "rollbackDetail": "在仍可用時,從 TestFlight > 先前的組建中使用 cmux BETA 1.0.5 ({rollbackBuild})。請選擇此確切組建,因為後續組建使用更新的連線服務。" } } diff --git a/web/tests/whats-new-route.test.ts b/web/tests/whats-new-route.test.ts index 02301b2aa1ec..d0faa4276f84 100644 --- a/web/tests/whats-new-route.test.ts +++ b/web/tests/whats-new-route.test.ts @@ -132,6 +132,7 @@ describe("whats-new route channel targeting", () => { title: "What's New in 1.0.6", releaseLabel: "1.0.6 · September 2026", channels: ["beta", "internal"], + localizations: expect.objectContaining({ en: expect.objectContaining({ title: "What's New in 1.0.6" }) }), features: expect.arrayContaining([ expect.objectContaining({ title: "Updated Mac connections", @@ -143,4 +144,28 @@ describe("whats-new route channel targeting", () => { }); }); + test("serves translated release instructions with exact compatibility values", async () => { + const response = await GET(new Request("https://cmux.test/api/whats-new")); + const payload = await response.json() as WhatsNewList; + const notice = payload.announcements.find((entry) => entry.id === "ios-1.0.6-connections")!; + const translations = notice.localizations!; + expect(Object.keys(translations).sort()).toEqual([ + "ar", "bs", "da", "de", "en", "es", "fr", "it", "ja", "km", "ko", "no", + "pl", "pt-BR", "ru", "th", "tr", "uk", "zh-CN", "zh-TW", + ]); + for (const [locale, content] of Object.entries(translations)) { + expect(content.features[1].detail).toContain("0.64.25-nightly.3522337919701"); + expect(content.features[2].detail).toContain("1.0.5 (20260914204800)"); + expect(JSON.stringify(content)).not.toMatch(/\{(?:stableVersion|nightlyVersion|rollbackBuild)\}/); + if (locale !== "en") expect(content.features[2].detail).not.toBe(translations.en.features[2].detail); + } + }); + + test("rejects incomplete translated release instructions", () => { + expect(() => validateList({ + ...base, + announcements: [{ ...announcement, localizations: { ja: { title: "お知らせ", features: [] } } }], + })).toThrow("localizations[ja].features must not be empty"); + }); + });