diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift index abaddf38a6c9..a1bc7ae81ad8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift @@ -290,6 +290,7 @@ struct PageFactory { ? nil : DaemonUpdateBoard.updateRefused })), PageRoute(prefix: WebUpdatesPageProvider.prefix, provider: WebUpdatesPageProvider()), + PageRoute(prefix: AppUpdatePageProvider.prefix, provider: AppUpdatePageProvider(updater: services.updater)), // Chief > Voice input: the home.voiceInput tunable (DEV and NIGHTLY). PageRoute(prefix: ChiefVoiceInputPageRoute.prefix, provider: ChiefVoiceInputPageRoute()), // Settings > Menus: right-click menu arrangements (cmux.json contextMenus). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Settings/SettingsWindowService+Page.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Settings/SettingsWindowService+Page.swift index a1fd8919a34e..728082f47e7e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Settings/SettingsWindowService+Page.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Settings/SettingsWindowService+Page.swift @@ -8,6 +8,7 @@ import CmuxNextPages import CmuxNextSettings import CmuxNextServer import CmuxNextSettingsWindow +import CmuxNextUpdater import CmuxNextWebUpdates /// What the React Settings page shows beside the schema rows (R82 commit 2): `cmux.settings.host.lists`. @@ -66,6 +67,7 @@ extension SettingsWindowService { "server": services.serverUpdates.settingsPageJSON, "import_suggestions": ImportSuggestionsCenter.shared.settingsJSON, "web_updates": WebUpdates.shared.pageJSON, + "app_update": services.updater.settingsPageJSON, // Chief > Voice input: home.voiceInput, null where the tunable does not exist (Release). "voice_input": ChiefVoiceInputPageRoute().hostJSON, // Settings > Menus: every right-click menu's rows and the person's arrangement of it. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/UpdaterService+App.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/UpdaterService+App.swift index 66e3d52c57dd..6db8468f9470 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/UpdaterService+App.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/UpdaterService+App.swift @@ -36,14 +36,10 @@ extension UpdaterService { // active window's sidebar hidden (or no window) nothing would show, so the sheet // answers instead (never a check with no visible feedback). showsIndicator = { [weak services] in (services?.windows.active).map { !$0.state.sidebarHidden } ?? false } - openChangelog = { [weak services] in - return services.map { ChangelogPageTab.open($0) } ?? false - } + openChangelog = { [weak services] in services.map { ChangelogPageTab.open($0) } ?? false } // "See What's New" on the Updated card: the React changelog page // for the releases this update crossed (`#/?from=&to=`). - openChangelogSpan = { [weak services] from, to in - return services.map { ChangelogPageTab.open($0, from: from, to: to) } ?? false - } + openChangelogSpan = { [weak services] from, to in services.map { ChangelogPageTab.open($0, from: from, to: to) } ?? false } openReleaseNotesURL = { [weak services] url in guard let services, url.scheme == "https" else { return false } SidebarCardFeed.openUpdateLink(url, services: services) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsPageLayout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsPageLayout.swift index 39088b38b35d..fdef60ba6dcd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsPageLayout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsPageLayout.swift @@ -19,6 +19,9 @@ public nonisolated struct SettingsPageLayout: Sendable { case importSuggestions /// Every release channel's recent builds, with the one this Mac runs (Settings > Updates). case channels + /// The app update's status: a check, a download's progress and Cancel, Restart to Update, a + /// failure's Try Again (Settings > Updates; the sidebar shows only the staged update). + case appUpdate /// The web UI served now, its last check and Roll Back (plans/cmux-next/web-ota.md); NIGHTLY only. case webUpdates /// Settings > Chief's Voice input row: the Debug toggle home.voiceInput, only where it exists. @@ -184,7 +187,7 @@ public nonisolated struct SettingsPageLayout: Sendable { ], trail: [.spaces], actions: [.general, .rooms], aliases: [.general, .rooms]) - // How this Mac updates, then every channel's recent builds (docs/release-rings.md in hq). + // The app update's status, how this Mac updates, then every channel's recent builds (docs/release-rings.md in hq). private static let updates = CategorySpec( id: "updates", title: SettingsText.keyed("settings.category.updates", "Updates"), symbol: "icloud.and.arrow.down", groups: [ @@ -193,7 +196,7 @@ public nonisolated struct SettingsPageLayout: Sendable { title: SettingsText.keyed("settings.pageGroup.updatesAdvanced", "Updates: Advanced")), ], // After the groups: the Web UI card (the web half of Updates; NIGHTLY only), then the channels. - trail: [.webUpdates, .channels]) + lead: [.appUpdate], trail: [.webUpdates, .channels]) private static let theme = CategorySpec( id: "theme", title: SettingsText.keyed("settings.category.theme", "Theme"), symbol: "paintpalette", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings index 99e852798c3d..db33cfd7c068 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings @@ -27510,6 +27510,268 @@ } } } + }, + "updater.settings.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App Update" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリのアップデート" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث التطبيق" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Ažuriranje aplikacije" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Appopdatering" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Update" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualización de la app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour de l’app" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Aggiornamento app" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ការធ្វើបច្ចុប្បន្នភាពកម្មវិធី" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 업데이트" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Appoppdatering" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Aktualizacja aplikacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Atualização do app" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Обновление приложения" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "อัปเดตแอป" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Uygulama Güncellemesi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Оновлення застосунку" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Cập nhật ứng dụng" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "App 更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "App 更新" + } + } + } + }, + "updater.settings.checkNow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check Now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今すぐ確認" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحقق الآن" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Provjeri sada" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Søg nu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt suchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Controlla ora" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ពិនិត្យឥឡូវ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 확인" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Se etter nå" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Sprawdź teraz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Verificar agora" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Проверить" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ตรวจสอบเลย" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Şimdi Denetle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Перевірити" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Kiểm tra ngay" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即检查" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即檢查" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateFlow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateFlow.swift index 11a74bf1747b..0c5d5bde38f4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateFlow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateFlow.swift @@ -49,12 +49,12 @@ nonisolated public struct UpdateFlow: Equatable, Sendable { userAsked = false return installIfStaged() case .downloading, .checking: - // Installs once staged; the progress shows meanwhile. + // Installs once staged; a check shows meanwhile, a download runs quietly. installRequested = true userAsked = true return [] case .available: - // Downloads, shows its progress, installs once staged. + // Downloads quietly, installs once staged. installRequested = true userAsked = true return [.download] @@ -62,8 +62,8 @@ nonisolated public struct UpdateFlow: Equatable, Sendable { return [] } case .downloadRequested: - // The answer to the update-found step: download, show the progress, then ask again - // before the relaunch (the staged update card). A held install click keeps its install. + // The answer to the update-found step: download quietly, then ask again before the + // relaunch (the staged update card). A held install click keeps its install. guard case .available = phase else { return [] } userAsked = true return [.download] @@ -131,8 +131,13 @@ nonisolated public struct UpdateFlow: Equatable, Sendable { } /// The notice card's update status, or nil: what the user asked for (a - /// check, its download, its result), and a found update that waits for - /// a click (`updates.downloadAutomatically` off), which needs no check. + /// check, its result), and a found update that waits for a click + /// (`updates.downloadAutomatically` off), which needs no check. A + /// download never shows here, asked for or not: it runs quietly and the + /// staged update's one-step card asks to restart (Leo 2026-11-03, after + /// ChatGPT: "it's already downloaded you click this button to accept the + /// button and restart"). Its progress, Cancel and a failure's Try Again + /// are Settings > Updates' (``UpdaterService/settingsStatus``). /// A scheduled check, its download and its result show nothing. /// A staged update is never this card (the staged update card, /// ``footerPill(preferences:)``); an install shows here only when the @@ -149,10 +154,9 @@ nonisolated public struct UpdateFlow: Equatable, Sendable { guard userAsked else { return nil } switch phase { case .checking: return .checking - case .downloading(let transfer): return .downloading(transfer) case .installing: return .installing case .note(let note): return .note(note) - case .hidden, .available, .ready: return nil + case .hidden, .available, .downloading, .ready: return nil } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+SettingsStatus.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+SettingsStatus.swift new file mode 100644 index 000000000000..e71f343342e7 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+SettingsStatus.swift @@ -0,0 +1,166 @@ +public import CmuxNextPages +public import CmuxNextSettings +import Foundation + +/// Settings > Updates' App Update card: where a download shows its progress and its Cancel now +/// that the sidebar shows none (``UpdateFlow/card(downloadsAutomatically:)``), and where a failed +/// check or download says so with Try Again, whoever started it. The sidebar keeps only the +/// staged update's one-step Restart to Update card (Leo 2026-11-03). +extension UpdaterService { + /// What the card shows: the flow's phase whether or not the person asked, so a background + /// download and its failure show too. Nil while nothing runs and nothing waits (the card + /// then offers Check Now). + public var settingsStatus: SettingsUpdateStatus? { + if let newestCard { return .card(newestCard) } + if let cancelledDownloadCard { return .card(cancelledDownloadCard) } + switch flow.phase { + case .hidden: return nil + case .checking: return .card(.checking) + case .downloading(let transfer): return .card(.downloading(transfer)) + case .available(let version): return .card(.available(version: version)) + case .ready(let version): return .ready(version: version) + case .installing: return .card(.installing) + case .note(let note): return .card(.note(note)) + } + } + + /// The card's state (`app_update` in `cmux.settings.host.lists`), every text localized here. + /// Live through `cmux.settings.host.changed`: it reads only observable state. A download is + /// whole percent (``settingsQuantized(_:)``), so it re-sends the host lists at most a hundred times. + public var settingsPageJSON: JSONValue { + let current = UpdateCard.versionLabel(version: identity.shortVersion, build: identity.build) + var json: [String: JSONValue] = ["title": .string(UpdaterStrings.settingsTitle)] + let actions: [SettingsUpdateAction] + switch settingsStatus { + case nil: + json["status"] = .string(UpdaterStrings.youHave(current)) + json["detail"] = .null + actions = [.check] + case .ready(let version): + json["status"] = .string(UpdaterStrings.readyToInstall) + json["detail"] = .string(version.map(UpdaterStrings.foundVersion) ?? UpdaterStrings.restartKeepsSessions) + actions = [.install] + case .card(let shown): + let card = Self.settingsQuantized(shown) + let shown = card.presentation(version: identity.shortVersion, build: identity.build, + install: card == .installing ? installStatus : nil) + json["status"] = .string(shown.title) + // A failure names its cause when Sparkle gave one. + let failed = card == .note(.checkFailed) + json["detail"] = (failed ? failureReason ?? shown.detail : shown.detail).map(JSONValue.string) ?? .null + if shown.showsProgress { + json["progress"] = shown.progress.map { .number(($0 * 100).rounded() / 100) } ?? .null + } + actions = Self.settingsActions(card, shown) + } + json["phase"] = .string(settingsStatus?.kind ?? "idle") + json["actions"] = .array(actions.map { ["id": .string($0.rawValue), "title": .string($0.title), "prominent": .bool($0.isProminent)] }) + return .object(json) + } + + /// `card` with a measured download's bytes rounded down to whole percent, so neither the bar + /// nor the bytes line changes between percents and the host lists re-send at most a hundred times. + nonisolated static func settingsQuantized(_ card: UpdateCard) -> UpdateCard { + guard case .downloading(let transfer) = card, transfer.stage == .downloading, + let received = transfer.receivedBytes, let expected = transfer.expectedBytes, expected > 0 else { return card } + let percent = min(100, received * 100 / expected) + return .downloading(.downloading(received: expected * percent / 100, expected: expected)) + } + + /// The card's buttons for a notice `card`: the same as the old sidebar card's (Update, + /// Cancel, Try Again), without the links the notice card had (Release Notes, Details). + nonisolated static func settingsActions(_ card: UpdateCard, _ shown: UpdateCardPresentation) -> [SettingsUpdateAction] { + var actions = (shown.actions + [shown.trailing].compactMap(\.self)).compactMap(SettingsUpdateAction.init) + if case .note(.upToDate) = card { actions.append(.check) } + return actions + } + + /// A click on the card (the person's own, through ``AppUpdatePageProvider``). + public func performSettingsAction(_ action: SettingsUpdateAction) { + switch action { + case .check: _ = checkForUpdates() + case .update: downloadClicked() + case .cancelDownload: cancelDownloadClicked() + case .retry: _ = checkForUpdates() + case .install: installClicked() + } + } +} + +/// What Settings > Updates' App Update card shows (``UpdaterService/settingsStatus``). +public nonisolated enum SettingsUpdateStatus: Equatable, Sendable { + /// A check, a download, a found update, an install or a result, as the notice card words it. + case card(UpdateCard) + /// Staged: Restart to Update installs and relaunches. + case ready(version: String?) + + /// A stable name for the page and scripts. + public var kind: String { + switch self { + case .card(let card): card.kind + case .ready: "ready" + } + } +} + +/// A button of the App Update card. The raw value is the action id the page sends. +public nonisolated enum SettingsUpdateAction: String, Sendable, CaseIterable { + case check + case update + case cancelDownload = "cancel_download" + case retry + case install + + /// The notice card's action this button stands for; nil for one the card leaves out. + init?(_ action: UpdateCardAction) { + switch action { + case .update: self = .update + case .cancelDownload: self = .cancelDownload + case .retry: self = .retry + case .releaseNotes, .details: return nil + } + } + + public var title: String { + switch self { + case .check: UpdaterStrings.settingsCheckNow + case .update: UpdaterStrings.update + case .cancelDownload: UpdaterStrings.cancel + case .retry: UpdaterStrings.retry + case .install: UpdaterStrings.restartToUpdate + } + } + + /// The call to action (filled); the others are quiet. + public var isProminent: Bool { self == .install || self == .update } +} + +/// Settings > Updates' App Update card's buttons (`cmux.settings.app_update.run {action}`), routed +/// here by prefix. The card reads ``UpdaterService/settingsPageJSON`` from the host lists. +@MainActor +public final class AppUpdatePageProvider: PageProvider { + public static let prefix = "cmux.settings.app_update." + private let updater: UpdaterService + + public init(updater: UpdaterService) { + self.updater = updater + } + + public func call(_ op: String, params: JSONValue, context: PageCallContext) async throws -> JSONValue { + guard op == Self.prefix + "run" else { throw PageError.unknownOp(op) } + guard let id = params["action"]?.stringValue, let action = SettingsUpdateAction(rawValue: id) else { + throw PageError.invalidParams("action is one of \(SettingsUpdateAction.allCases.map(\.rawValue))") + } + // Restart to Update relaunches the app: only the person's own click in this page. + if action == .install, !context.userGesture { + throw PageError(code: PageNativeOp.userOnlyCode, message: "Restart to Update needs the person's click") + } + updater.performSettingsAction(action) + return .object([:]) + } +} + +nonisolated extension UpdaterStrings { + static var settingsTitle: String { text("updater.settings.title", "App Update") } + static var settingsCheckNow: String { text("updater.settings.checkNow", "Check Now") } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateAsksBeforeInstallTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateAsksBeforeInstallTests.swift index 6c3742f256bc..57a3b7532c13 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateAsksBeforeInstallTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateAsksBeforeInstallTests.swift @@ -71,7 +71,7 @@ import Testing } /// Check for Updates: the found update's card shows its version, Update and Release Notes, and - /// Hide; Update downloads it with its progress, and the staged update asks before the + /// Hide; Update downloads it quietly, and the staged update asks before the /// relaunch. Only Restart to Update installs and relaunches. @Test(arguments: UpdateAsksBeforeInstallTests.channels) func checkForUpdatesAsksBeforeTheDownloadAndBeforeTheRelaunch(identity: UpdateBuildIdentity) async throws { @@ -85,12 +85,13 @@ import Testing #expect(found.actions == [.update, .releaseNotes] && found.dismissible, "\(identity.track): the found step offers \(found.actions)") #expect(effects.downloads == 0) - // Update: the download starts and shows; nothing installs. + // Update: the download starts quietly (Settings > Updates shows it); nothing installs. updater.performCardAction(.update) #expect(effects.downloads == 1, "\(identity.track): Update did not download") #expect(!updater.flow.installRequested, "\(identity.track): Update on the found step also asked to relaunch") updater.debugIndicatorPhase = .downloading(UpdateTransfer(stage: .downloading, fraction: 0.5)) - #expect(updater.card == .downloading(UpdateTransfer(stage: .downloading, fraction: 0.5)), "the download shows no progress") + #expect(updater.card == nil, "the download shows a sidebar card") + #expect(updater.settingsStatus == .card(.downloading(UpdateTransfer(stage: .downloading, fraction: 0.5))), "Settings shows no progress") // Staged: the Update Ready card asks; nothing relaunches by itself. updater.debugIndicatorPhase = .ready(version: "2.0.0") diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateDownloadCancelTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateDownloadCancelTests.swift index a7265b1d3e60..c94e90d664a4 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateDownloadCancelTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateDownloadCancelTests.swift @@ -66,9 +66,11 @@ import Testing updater.debugIndicatorPhase = phase updater.syncFlowPhase() } - #expect(updater.card == .downloading(.downloading(received: 62 * Self.mb, expected: 250 * Self.mb))) + #expect(updater.card == nil, "the download shows a sidebar card") + #expect(updater.settingsStatus == .card(.downloading(.downloading(received: 62 * Self.mb, expected: 250 * Self.mb)))) - updater.performCardAction(.cancelDownload) + // The cancel is Settings > Updates' now (the sidebar shows no download). + updater.performSettingsAction(.cancelDownload) #expect(cancels == 1, "the cancel did not stop the download") #expect(updater.card == .available(version: nil), "the card did not go back to the found update: \(String(describing: updater.card))") let notice = SidebarCardFeed.noticeCard(updater, registry: nil) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateLoadingIndicatorTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateLoadingIndicatorTests.swift index 0d4e04ed2b75..1bb19e6dbcce 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateLoadingIndicatorTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateLoadingIndicatorTests.swift @@ -36,18 +36,27 @@ import Testing card.presentation(version: "1.0.0", build: "100") } - @Test func attemptUpdateShowsOneLoadingCardFromCheckToRelaunch() throws { + /// Attempt Update: the check and the install show on the sidebar's notice card; the download + /// between them runs quietly (Leo 2026-11-03, after ChatGPT's one-step update), its stages + /// named in Settings > Updates instead (``UpdaterService/settingsStatus``). + @Test func attemptUpdateShowsTheCheckAndTheInstallButNotTheDownload() throws { var flow = UpdateFlow() _ = flow.handle(.checkRequested, preferences: .defaults) var titles: [String] = [] for phase in Self.attemptPhases { _ = flow.handle(.sparkle(phase), preferences: .defaults) + if case .downloading = phase { + #expect(flow.card(downloadsAutomatically: true) == nil, "the sidebar shows the download at \(phase)") + titles.append(Self.presentation(.downloading(Self.transfer(phase))).title) + continue + } let card = try #require(flow.card(downloadsAutomatically: true), "the loading card left at \(phase)") let shown = Self.presentation(card) #expect(shown.showsProgress, "\(phase) shows no progress") #expect(!shown.dismissible, "\(phase) can be dismissed while it works") - if titles.last != shown.title { titles.append(shown.title) } + titles.append(shown.title) } + titles = titles.reduce(into: []) { if $0.last != $1 { $0.append($1) } } #expect(titles == [UpdaterStrings.checking, UpdaterStrings.startingDownload, UpdaterStrings.downloading, UpdaterStrings.preparing, UpdaterStrings.installingUpdate], "the stages are not named in order: \(titles)") @@ -144,7 +153,11 @@ import Testing for phase in Self.attemptPhases { updater.debugIndicatorPhase = phase updater.syncFlowPhase() - #expect(updater.card != nil, "the loading card left at \(phase)") + if case .downloading = phase { + #expect(updater.card == nil, "the sidebar shows the download at \(phase)") + } else { + #expect(updater.card != nil, "the loading card left at \(phase)") + } } #expect(updater.card == .installing) #expect(updater.readyCard == nil, "two cards say the update installs") @@ -189,7 +202,8 @@ import Testing } /// The same phases after the person ran Check for Updates (menu or - /// Cmd+Shift+P, `palette.checkForUpdates`) show the loading card. + /// Cmd+Shift+P, `palette.checkForUpdates`) show the check and the install + /// on the loading card; the download shows only in Settings > Updates. @Test func checkForUpdatesFromTheCommandShowsTheLoadingCard() { let updater = Self.updater() let registry = ActionRegistry(catalog: ActionCatalog.all, aliases: ActionCatalog.legacyAliases) @@ -198,10 +212,20 @@ import Testing for phase in Self.attemptPhases { updater.debugIndicatorPhase = phase updater.syncFlowPhase() - #expect(updater.card != nil, "the person's update showed no loading card at \(phase)") + if case .downloading(let transfer) = phase { + #expect(updater.card == nil, "the person's download showed a sidebar card at \(phase)") + #expect(updater.settingsStatus == .card(.downloading(transfer)), "Settings does not show the download at \(phase)") + } else { + #expect(updater.card != nil, "the person's update showed no loading card at \(phase)") + } } } + static func transfer(_ phase: UpdateIndicatorPhase) -> UpdateTransfer { + guard case .downloading(let transfer) = phase else { return .starting } + return transfer + } + static func updater() -> UpdaterService { let identity = UpdateBuildIdentity(bundleIdentifier: "com.cmuxterm.app.debug.update-loading", shortVersion: "1.0.0", build: "100", minimumSystemVersion: nil, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateOneStepTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateOneStepTests.swift new file mode 100644 index 000000000000..e90bb7c90ab9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateOneStepTests.swift @@ -0,0 +1,133 @@ +import AppKit +@testable import CmuxNextApp +import CmuxNextPages +import CmuxNextSettings +@testable import CmuxNextSidebar +@testable import CmuxNextUpdater +import Foundation +import Testing + +/// Leo 2026-11-03, on the sidebar's Downloading Update card: "idk chatgpt's seems to be just like a +/// one step "ok it's already downloaded you click this button to accept the button and restart" idk +/// if we wanna follow that too". The download runs with no sidebar card; the staged update is the +/// one Restart to Update card, whose click installs and relaunches; the download's progress, its +/// Cancel and a failure's Try Again are Settings > Updates' App Update card. +@MainActor @Suite(.serialized) struct UpdateOneStepTests { + static let mb: UInt64 = 1_000_000 + static let half = UpdateIndicatorPhase.downloading(.downloading(received: 125 * mb, expected: 250 * mb)) + + static func make() -> (UpdaterService, UpdateAsksBeforeInstallTests.Effects) { + UpdateAsksBeforeInstallTests.make(UpdateAsksBeforeInstallTests.nightly) + } + + static func move(_ updater: UpdaterService, _ phases: [UpdateIndicatorPhase]) { + for phase in phases { + updater.debugIndicatorPhase = phase + updater.syncFlowPhase() + } + } + + func eventually(_ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(3) + while !condition(), ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(10)) } + return condition() + } + + /// The App Update card's buttons, by action id. + static func actions(_ json: JSONValue) -> [String] { + json["actions"]?.arrayValue?.compactMap { $0["id"]?.stringValue } ?? [] + } + + @Test func aDownloadShowsNoSidebarCardEvenWhenThePersonAsked() { + let (updater, _) = Self.make() + updater.send(.checkRequested) + Self.move(updater, [.checking, .available(version: "2.0.0")]) + updater.performCardAction(.update) + for phase: UpdateIndicatorPhase in [.downloading(.starting), Self.half, .downloading(.preparing(0.5))] { + Self.move(updater, [phase]) + #expect(updater.card == nil, "the sidebar shows the download at \(phase)") + #expect(SidebarCardFeed.noticeCard(updater, registry: nil)?.id != SidebarCardFeed.updateCardID, "a notice card took \(phase)") + #expect(SidebarCardFeed.updateCard(updater) == nil, "the ready card shows before the update is staged") + } + } + + @Test func theStagedUpdateIsOneRestartCardWhoseClickInstalls() async throws { + let (updater, effects) = Self.make() + Self.move(updater, [.checking, .downloading(.starting), Self.half, .ready(version: "2.0.0")]) + #expect(updater.card == nil, "a notice card shows beside the ready card") + let card = try #require(SidebarCardFeed.updateCard(updater), "the staged update shows no card") + #expect(card.buttonTitle == UpdaterStrings.restartToUpdate && card.isEnabled) + + // The card's one button sends installUpdate; SidebarCardFeed.handle runs installClicked. + let view = SidebarUpdateCardView(frame: NSRect(x: 0, y: 0, width: 216, height: 120)) + view.configure(card) + var sent = 0 + view.onInstall = { sent += 1; updater.installClicked() } + view.button.onPress?() + #expect(sent == 1) + #expect(await eventually { effects.installs == 1 }, "Restart to Update did not install and relaunch") + } + + @Test func settingsShowsTheDownloadsProgressAndItsCancel() throws { + let (updater, _) = Self.make() + #expect(updater.settingsStatus == nil) + #expect(Self.actions(updater.settingsPageJSON) == ["check"], "an idle card offers no Check Now") + + Self.move(updater, [.checking, .downloading(.starting), Self.half]) + #expect(updater.settingsStatus == .card(.downloading(.downloading(received: 125 * Self.mb, expected: 250 * Self.mb)))) + let json = updater.settingsPageJSON + #expect(json["phase"]?.stringValue == "downloading") + #expect(json["status"]?.stringValue == UpdaterStrings.downloading) + #expect(json["progress"]?.doubleValue == 0.5, "Settings shows no progress: \(json)") + #expect(json["detail"]?.stringValue?.isEmpty == false, "Settings shows no bytes line") + #expect(Self.actions(json) == ["cancel_download"]) + + Self.move(updater, [.ready(version: "2.0.0")]) + let ready = updater.settingsPageJSON + #expect(ready["phase"]?.stringValue == "ready") + #expect(Self.actions(ready) == ["install"]) + #expect(ready["progress"] == nil) + } + + @Test func aDownloadReSendsSettingsOnlyWhenItsPercentChanges() { + let (updater, _) = Self.make() + Self.move(updater, [.checking, .downloading(.downloading(received: 125 * Self.mb, expected: 250 * Self.mb))]) + let first = updater.settingsPageJSON + Self.move(updater, [.downloading(.downloading(received: 125 * Self.mb + 300_000, expected: 250 * Self.mb))]) + #expect(updater.settingsPageJSON == first, "the bytes line changed inside one percent") + Self.move(updater, [.downloading(.downloading(received: 128 * Self.mb, expected: 250 * Self.mb))]) + #expect(updater.settingsPageJSON != first, "a new percent did not show") + } + + @Test func aBackgroundFailureShowsInSettingsWithTryAgain() { + let (updater, _) = Self.make() + // A scheduled check and download the person never asked for, then Sparkle's error. + Self.move(updater, [.checking, Self.half, .note(.checkFailed)]) + #expect(updater.card == nil, "a background failure took the sidebar") + #expect(updater.settingsStatus == .card(.note(.checkFailed)), "the failure shows nowhere") + #expect(Self.actions(updater.settingsPageJSON) == ["retry"]) + } + + @Test func theCancelAndTheRestartGoThroughTheProvider() async throws { + let (updater, effects) = Self.make() + var cancels = 0 + updater.cancelDownloadNow = { + cancels += 1 + updater.debugIndicatorPhase = .hidden + return true + } + Self.move(updater, [.checking, Self.half]) + let provider = AppUpdatePageProvider(updater: updater) + let click = PageCallContext(page: "cmux.settings", userGesture: true) + _ = try await provider.call("cmux.settings.app_update.run", params: ["action": "cancel_download"], context: click) + #expect(cancels == 1, "Cancel in Settings did not stop the download") + + Self.move(updater, [.checking, Self.half, .ready(version: "2.0.0")]) + let script = PageCallContext(page: "cmux.settings") + await #expect(throws: PageError.self, "a call with no click restarted the app") { + try await provider.call("cmux.settings.app_update.run", params: ["action": "install"], context: script) + } + _ = try await provider.call("cmux.settings.app_update.run", params: ["action": "install"], context: click) + #expect(await eventually { effects.installs == 1 }, "Restart to Update in Settings did not install") + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/UpdatesCategoryLayoutTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/UpdatesCategoryLayoutTests.swift index da5c932fb3e1..aaee11094e96 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/UpdatesCategoryLayoutTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/UpdatesCategoryLayoutTests.swift @@ -1,7 +1,8 @@ import CmuxNextSettings import Testing -/// Settings > Updates is its own category: how this Mac updates, then the Web UI card and the +/// Settings > Updates is its own category: the App Update card (a download's progress and its +/// Cancel, Restart to Update, a failure's Try Again), how this Mac updates, then the Web UI card and the /// Channels card with every channel's recent builds. General no longer holds the update rows. struct UpdatesCategoryLayoutTests { private static let categories = SettingsPageLayout().categories() @@ -9,6 +10,7 @@ struct UpdatesCategoryLayoutTests { @Test func updatesHoldsTheUpdateRowsThenTheWebUIAndChannelsCards() throws { let updates = try #require(Self.categories.first { $0.spec.id == "updates" }) #expect(updates.groups.flatMap(\.rows).contains("updates.channel")) + #expect(updates.spec.lead == [.appUpdate]) #expect(updates.spec.trail == [.webUpdates, .channels]) #expect(Self.categories.map(\.spec.id).prefix(2) == ["general", "updates"]) } diff --git a/plans/cmux-next/sidebar-owner.md b/plans/cmux-next/sidebar-owner.md index d70f57f223db..15a05dfc2204 100644 --- a/plans/cmux-next/sidebar-owner.md +++ b/plans/cmux-next/sidebar-owner.md @@ -7,6 +7,12 @@ takes `RowBadgeView.spinnerSide` (`SidebarStyle.statusGlyphSize`: 10 pt compact, 12 comfortable) on the same cell center; the red warning and needs-you marks keep 14 pt. Regression: `RowBadgeSpinnerDensityTests` (every density and minimal mode), `RowBadgeMarkSizeTests.theSpinnerIsSmallerThanTheWarning`. +- 2026-11-03: a download shows no sidebar card; the staged update is the one Restart to Update card. Leo, verbatim, on + the Downloading Update card: "also idk chatgpt's seems to be just like a one step "ok it's already downloaded you click + this button to accept the button and restart" idk if we wanna follow that too". `UpdateFlow.card` returns nil while + downloading, asked for or not; the progress, its Cancel and a failure's Try Again moved to Settings > Updates' App + Update card (`UpdaterService.settingsStatus`, `AppUpdatePageProvider`). The ready card itself is unchanged. + Regression: `UpdateOneStepTests`. - 2026-11-03: the update and notice cards take the theme. Leo, verbatim, on the Downloading Update card: "will this be themeed properly?". The download bar was the fixed secondary-text gray (`Palette.textSecondary`) and the Update Ready and notice cards' veil the neutral gray; the bar is now the theme's accent (`Palette.accentFill`, the primary diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index c0334d4a92fd..00e50c9d2ddc 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -357,7 +357,7 @@ ], "id" : "updates", "lead" : [ - + "appUpdate" ], "symbol" : "icloud.and.arrow.down", "title" : { diff --git a/webviews/src/pages/settings/appUpdate.gallery.ts b/webviews/src/pages/settings/appUpdate.gallery.ts new file mode 100644 index 000000000000..bde6980e74de --- /dev/null +++ b/webviews/src/pages/settings/appUpdate.gallery.ts @@ -0,0 +1,67 @@ +// l10n-allow-file: gallery fixtures, not shipped UI. +// Updates > App Update (AppUpdateCard): the app update's status, from the host lists' `app_update`. +// The sidebar shows no download (Leo 2026-11-03, after ChatGPT's one-step update), so this card +// carries the progress, Cancel, Restart to Update and a failure's Try Again. One variant per state. +import { settingsPageEntry, type PageFixtureStep, type SettingsPageVariant } from "../../gallery/format"; +import type { AppUpdateState } from "./ops"; + +const idle: AppUpdateState = { + title: "App Update", + phase: "idle", + status: "You have 1.0.0-nightly.3913596493801.", + detail: null, + actions: [{ id: "check", title: "Check Now", prominent: false }], +}; +const card = (phase: string): PageFixtureStep => ({ + selector: `[data-card="app-update"][data-phase="${phase}"]`, + action: "wait", +}); +const variant = (state: AppUpdateState, note: string): SettingsPageVariant => ({ + section: "updates", + host: { app_update: state }, + steps: [card(state.phase)], + note, +}); + +export default settingsPageEntry({ + id: "pages.settings-app-update", + title: "Settings App Update", + area: "Settings", + height: 520, + covers: ["pages/settings/components/AppUpdateCard.tsx"], + anchors: [{ selector: '[data-card="app-update"]' }], + variants: { + idle: variant(idle, "Nothing runs: the running version and Check Now."), + downloading: variant( + { + ...idle, + phase: "downloading", + status: "Downloading Update", + detail: "57.3 MB of 257.1 MB · 22%", + progress: 0.22, + actions: [{ id: "cancel_download", title: "Cancel", prominent: false }], + }, + "A download, background or asked for: its bytes, the bar and Cancel (the sidebar shows none).", + ), + ready: variant( + { + ...idle, + phase: "ready", + status: "Update Ready", + detail: "Version 1.0.0-nightly.3913596493802", + actions: [{ id: "install", title: "Restart to Update", prominent: true }], + }, + "Staged: one Restart to Update, the same click as the sidebar's ready card.", + ), + failed: variant( + { + ...idle, + phase: "check_failed", + status: "Update Check Failed", + detail: "The network connection was lost.", + actions: [{ id: "retry", title: "Try Again", prominent: false }], + }, + "A failed check or download, background ones too: the cause and Try Again.", + ), + }, +}); diff --git a/webviews/src/pages/settings/appUpdateCard.test.tsx b/webviews/src/pages/settings/appUpdateCard.test.tsx new file mode 100644 index 000000000000..5646ef75b0fe --- /dev/null +++ b/webviews/src/pages/settings/appUpdateCard.test.tsx @@ -0,0 +1,67 @@ +// Updates > App Update: the card shows the host's update status (host lists `app_update`), follows +// it live, draws the download's bar, and its buttons send cmux.settings.app_update.run. A host +// with no updater shows no card. +import { act } from "react"; +import { afterAll, expect, test } from "bun:test"; +import type { AppUpdateState } from "./ops"; +import { installDom } from "./testDom"; + +const restore = installDom(); +afterAll(() => restore()); +const { renderPage, settle } = await import("./testing"); + +const card = (container: HTMLElement) => container.querySelector('[data-card="app-update"]'); +const downloading: AppUpdateState = { + title: "App Update", + phase: "downloading", + status: "Downloading Update", + detail: "57.3 MB of 257.1 MB · 22%", + progress: 0.22, + actions: [{ id: "cancel_download", title: "Cancel", prominent: false }], +}; +const ready: AppUpdateState = { + ...downloading, + phase: "ready", + status: "Update Ready", + detail: null, + progress: undefined, + actions: [{ id: "install", title: "Restart to Update", prominent: true }], +}; + +async function show(state: AppUpdateState | null) { + const page = await renderPage({ path: "/settings/updates" }); + await act(async () => page.provider.setHost({ ...page.provider.host, app_update: state })); + await settle(); + return page; +} + +test("no updater: no card", async () => { + const page = await show(null); + expect(card(page.container)).toBeNull(); + page.unmount(); +}); + +test("a download shows its bytes, its bar and Cancel", async () => { + const page = await show(downloading); + const element = card(page.container)!; + expect(element.dataset.phase).toBe("downloading"); + expect(element.textContent).toContain("57.3 MB of 257.1 MB"); + const bar = element.querySelector("[data-app-update-progress]")!; + expect(bar.value).toBe(22); + expect(element.querySelector('[data-app-update="cancel_download"]')).not.toBeNull(); + page.unmount(); +}); + +test("the staged update offers one Restart to Update, which sends install", async () => { + const page = await show(downloading); + await act(async () => page.provider.setHost({ ...page.provider.host, app_update: ready })); + await settle(); + const element = card(page.container)!; + expect(element.querySelector("[data-app-update-progress]")).toBeNull(); + const restart = element.querySelector('[data-app-update="install"]')!; + expect(restart.textContent).toBe("Restart to Update"); + await act(async () => restart.click()); + await settle(); + expect(page.provider.log).toContainEqual({ op: "cmux.settings.app_update.run", params: { action: "install" } }); + page.unmount(); +}); diff --git a/webviews/src/pages/settings/categories.ts b/webviews/src/pages/settings/categories.ts index 0237d85a223d..b76e7c127f04 100644 --- a/webviews/src/pages/settings/categories.ts +++ b/webviews/src/pages/settings/categories.ts @@ -28,6 +28,7 @@ export const CATEGORY_CARDS = [ "density", "importSuggestions", "channels", + "appUpdate", "webUpdates", "voiceInput", "notices", diff --git a/webviews/src/pages/settings/components/AppUpdateCard.tsx b/webviews/src/pages/settings/components/AppUpdateCard.tsx new file mode 100644 index 000000000000..905ff7ed0975 --- /dev/null +++ b/webviews/src/pages/settings/components/AppUpdateCard.tsx @@ -0,0 +1,54 @@ +// Updates > App Update: what the app update does now, from the host lists' `app_update` (the app's +// UpdaterService, live through cmux.settings.host.changed). A download runs with no sidebar card +// (Leo 2026-11-03, after ChatGPT's one-step update), so its progress and Cancel live here, and so +// does a failed check's or download's Try Again, background ones included. Every text is the host's. +import { useState } from "react"; +import { useSettingsState, useStore } from "../context"; +import type { AppUpdateAction } from "../ops"; +import { ActionRow } from "./ActionRow"; + +export function AppUpdateCard() { + const store = useStore(); + const { host } = useSettingsState(); + const [running, setRunning] = useState(null); + const state = host?.app_update; + if (!state) return null; + const run = async (action: AppUpdateAction) => { + setRunning(action); + await store.runAppUpdate(action); + setRunning(null); + }; + const progress = state.progress; + return ( +
+

{state.title}

+
+ + {state.actions.map((action) => ( + + ))} + + {progress !== undefined && ( +
+ +
+ )} +
+
+ ); +} diff --git a/webviews/src/pages/settings/components/SectionView.tsx b/webviews/src/pages/settings/components/SectionView.tsx index dd130cf03c79..aa1d033f8586 100644 --- a/webviews/src/pages/settings/components/SectionView.tsx +++ b/webviews/src/pages/settings/components/SectionView.tsx @@ -24,6 +24,7 @@ import { DENSITY_ROWS } from "../density"; import { DensityCard } from "./DensityCard"; import { BrowserProfiles, MACHINE_COLORS_KEY, MachinesSection, SpacesSection } from "./HostSections"; import { PlaceholderSection } from "./PlaceholderSection"; +import { AppUpdateCard } from "./AppUpdateCard"; import { WebUpdatesCard } from "./WebUpdatesCard"; import { VoiceInputCard } from "./VoiceInputCard"; import { ContextMenusCard } from "./ContextMenusCard"; @@ -66,6 +67,7 @@ const CARDS: Record , importSuggestions: () => , channels: (focus) => , + appUpdate: () => , webUpdates: () => , voiceInput: () => , notices: () => , diff --git a/webviews/src/pages/settings/mockProvider.ts b/webviews/src/pages/settings/mockProvider.ts index 15355dfb2231..111cd75c738d 100644 --- a/webviews/src/pages/settings/mockProvider.ts +++ b/webviews/src/pages/settings/mockProvider.ts @@ -485,6 +485,7 @@ export class MockSettingsProvider { "cmux.settings.backdrop.workspace.preview": () => ({}), "cmux.settings.art.search": () => ({ results: this.museumArt }), "cmux.settings.art.use": () => ({ id: "" }), + "cmux.settings.app_update.run": () => ({}), "cmux.settings.web_updates.check": () => ({}), "cmux.settings.web_updates.rollback": () => ({}), "cmux.settings.folders.add": (params) => { @@ -613,6 +614,7 @@ export class MockSettingsProvider { "cmux.settings.backdrop.workspace.preview", "cmux.settings.art.search", "cmux.settings.art.use", + "cmux.settings.app_update.run", "cmux.settings.web_updates.check", "cmux.settings.web_updates.rollback", "cmux.settings.server.restart", diff --git a/webviews/src/pages/settings/ops.ts b/webviews/src/pages/settings/ops.ts index b990026e4652..c6173ff9cc7b 100644 --- a/webviews/src/pages/settings/ops.ts +++ b/webviews/src/pages/settings/ops.ts @@ -235,6 +235,8 @@ export type HostLists = { import_suggestions?: unknown; /** Updates > Web UI: the web UI served between app updates; null unless the bundle takes them (NIGHTLY). */ web_updates?: WebUpdatesState | null; + /** Updates > App Update: the app update's status, a download's progress and the buttons; every text localized by the host. */ + app_update?: AppUpdateState | null; /** Settings > Chief > Voice input: the Debug toggle home.voiceInput; null where it does not exist (Release). */ voice_input?: { on: boolean } | null; /** Settings > Menus: every right-click menu's rows and the person's arrangement of it. */ @@ -350,6 +352,24 @@ export type ComputerUsePhase = | "ready" | "version_mismatch"; +/** + * Updates > App Update (the app's UpdaterService): what the update does now, whether or not the + * person asked (a background download and its failure show here; the sidebar shows only the staged + * update's Restart to Update). `progress` is 0...1, null while unmeasured, absent with no bar. + * Every text is the host's. + */ +export type AppUpdateState = { + title: string; + phase: string; + status: string; + detail: string | null; + progress?: number | null; + actions: { id: AppUpdateAction; title: string; prominent: boolean }[]; +}; + +/** A button of the App Update card (Swift `SettingsUpdateAction`). */ +export type AppUpdateAction = "check" | "update" | "cancel_download" | "retry" | "install"; + /** A web update the app keeps: its commit and when that commit landed (unix seconds). */ export type WebUpdateVersion = { commit: string; committed_at: number }; @@ -1016,6 +1036,8 @@ export type SettingsOps = { "cmux.settings.server.pair": [Record, Record]; /** A phone's pairing request: Allow or Don't Allow (the person's own gesture only). */ "cmux.settings.mobile.answer_pair": [{ id: string; allow: boolean }, Record]; + /** Native: Updates > App Update's buttons (Restart to Update only on the person's own click); the host lists follow. */ + "cmux.settings.app_update.run": [{ action: AppUpdateAction }, Record]; /** Native: Updates > Web UI's Check Now (updates.web.check); the host lists follow the check. */ "cmux.settings.web_updates.check": [Record, Record]; /** Native: Updates > Web UI's Roll Back (updates.web.rollback), from the next safe point. */ diff --git a/webviews/src/pages/settings/store.ts b/webviews/src/pages/settings/store.ts index 713e973c513c..4a451dba5cc6 100644 --- a/webviews/src/pages/settings/store.ts +++ b/webviews/src/pages/settings/store.ts @@ -44,6 +44,7 @@ import { type AccountsState, type AgentsRun, type AgentsState, + type AppUpdateAction, type ChannelsState, type CloudEnvironmentsRun, type CloudEnvironmentsState, @@ -567,6 +568,13 @@ export class SettingsStore { await this.refreshHost(); } + /** Updates > App Update: one of its buttons, then re-read the host lists; false when the host refused. */ + async runAppUpdate(action: AppUpdateAction): Promise { + const reply = await this.request("cmux.settings.app_update.run", { action }); + await this.refreshHost(); + return reply.ok; + } + /** Updates > Web UI: Check Now or Roll Back, then re-read the host lists; false when the host refused. */ async runWebUpdates(action: "check" | "rollback"): Promise { const reply = await this.request(`cmux.settings.web_updates.${action}` as const, {}); diff --git a/webviews/src/pages/settings/styles.css b/webviews/src/pages/settings/styles.css index b593adbf5b75..d3a5f8f0eae5 100644 --- a/webviews/src/pages/settings/styles.css +++ b/webviews/src/pages/settings/styles.css @@ -2128,3 +2128,38 @@ button.menu-disclosure { .number-list .token-input { font-variant-numeric: tabular-nums; } + +/* Updates > App Update: the download's bar under its status row, and Restart to Update filled. */ +.app-update-progress-row { + padding: 0 8px 8px; +} + +.app-update-progress { + display: block; + width: 100%; + block-size: 4px; + overflow: hidden; + border: 0; + border-radius: 2px; + appearance: none; + background: var(--input-bg); +} + +.app-update-progress::-webkit-progress-bar { + background: transparent; +} + +.app-update-progress::-webkit-progress-value { + border-radius: 2px; + background: var(--text); +} + +.app-update-progress::-moz-progress-bar { + border-radius: 2px; + background: var(--text); +} + +.button.app-update-prominent { + border-color: transparent; + background: var(--accent-soft); +}