Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ 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"),
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"),
Expand Down Expand Up @@ -84,6 +85,7 @@ public struct MobileMacCompatPolicy: Equatable, Sendable {
),
Tier(
minIOSVersion: nextIOS,
maxIOSVersion: nextIOS,
stableMinVersion: appStoreStableMin,
nightly: appStoreNightly,
buildKinds: [
Expand All @@ -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),
]
),
])
}()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,60 @@
#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"] ?? "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) }
return Data(payload.utf8)
}
))
}

public var body: some View {
NavigationStack {
MobileWhatsNewListView()
}
.overlay(alignment: .bottom) {
if hasRemoteFixture && center.lastRefreshSucceeded {
Text("Remote notice loaded")
.accessibilityIdentifier("MobileWhatsNewPreviewLoaded")
}
}
.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 }
)
.environment(center)
.preferredColorScheme(
ProcessInfo.processInfo.environment["CMUX_UITEST_WHATS_NEW_APPEARANCE"] == "dark"
? .dark : .light
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,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.
Expand All @@ -73,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? {
Expand All @@ -86,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
)
Expand All @@ -107,12 +116,31 @@ 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 ios106PairingUpdate: MobileWhatsNewPage {
MobileWhatsNewPage(
id: "pairing.1.0.6",
releaseLabel: L10n.string(
"mobile.ios106PairingUpdate.releaseLabel",
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,
channels: ["beta", "internal"],
minVersion: "1.0.6",
maxVersion: "1.0.6"
)
}

var pairingOptInUpdate: MobileWhatsNewPage {
MobileWhatsNewPage(
id: "connections.v2",
Expand All @@ -125,7 +153,8 @@ struct MobileWhatsNewCatalog: Sendable {
defaultValue: "Action Required: Enable iOS pairing on your Mac"
),
body: .pairingSetup([]),
isAnnouncement: false
isAnnouncement: false,
maxVersion: "1.0.5"
)
}

Expand Down Expand Up @@ -232,7 +261,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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,18 @@ 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<String> = ["connections.v2"]
private static let requiredBinaryEntryIDs: Set<String> = ["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
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.
Expand All @@ -51,12 +52,17 @@ 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?,
appVersion: String? = nil,
buildType: MobileBuildType = .current(),
defaults: UserDefaults = .standard,
preferredLanguages: [String] = Bundle.main.preferredLocalizations,
loader: Loader? = nil
) {
if let apiBaseURL, !apiBaseURL.isEmpty {
Expand All @@ -69,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) {
Expand All @@ -92,14 +99,23 @@ public final class MobileWhatsNewCenter {
/// failure (offline, server error, malformed payload) keeps the cached
/// list: cache wins while offline.
public func refresh() async {
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).
Expand Down Expand Up @@ -147,15 +163,16 @@ 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
)
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
}
Expand Down Expand Up @@ -259,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading