diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift index b2847bcdfcff..8ed754a9482f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift @@ -103,6 +103,8 @@ struct OnboardingFlowView: View { OnboardingAgentsView() case .notifications: OnboardingNotificationsView() + case .pushNotifications: + OnboardingPushNotificationsView() case .connect: OnboardingConnectionView( phase: connectionPhase, @@ -118,8 +120,10 @@ struct OnboardingFlowView: View { break case .notifications: showAgents() - case .connect: + case .pushNotifications: showNotifications() + case .connect: + showPushNotifications() } } @@ -128,6 +132,8 @@ struct OnboardingFlowView: View { case .agents: showNotifications() case .notifications: + showPushNotifications() + case .pushNotifications: showConnection() case .connect: if isAuthenticated { @@ -146,6 +152,10 @@ struct OnboardingFlowView: View { navigate(to: .notifications) } + private func showPushNotifications() { + navigate(to: .pushNotifications) + } + private func showConnection() { navigate(to: .connect) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPushNotificationsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPushNotificationsView.swift new file mode 100644 index 000000000000..b0d4f320f773 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPushNotificationsView.swift @@ -0,0 +1,72 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI +import UIKit + +/// Sets context for the system notification prompt with an expanded group of +/// notifications captured from iOS instead of approximating Apple's effects. +struct OnboardingPushNotificationsView: View { + var body: some View { + ZStack { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityIdentifier("MobileOnboardingPushNotificationsScene") + + OnboardingSceneContent( + title: title, + message: L10n.string( + "mobile.onboarding.pushNotifications.body", + defaultValue: "Get a push when work finishes or needs your input. Tap to open the right workspace." + ), + visual: OnboardingSystemNotificationPreview() + ) + } + } + + private var title: String { + L10n.string( + "mobile.onboarding.pushNotifications.title", + defaultValue: "Know when your agent needs you" + ) + } +} + +private struct OnboardingSystemNotificationPreview: View { + @Environment(\.locale) private var locale + + var body: some View { + Group { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + // 1,200 pixels is the native width of the @3x system capture. + // Capping at its point width prevents iOS typography from scaling up. + .frame(maxWidth: 400) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.string( + "mobile.onboarding.pushPreview.accessibilityLabel", + defaultValue: "Three cmux notifications for completed work, failed checks, and requested input." + )) + .accessibilityIdentifier("MobileOnboardingPushPreview") + } + + private var resourceName: String { + let language = OnboardingScreenshotLanguage.resolve(locale: locale) + return "Onboarding-push-\(language.rawValue)" + } + + private var image: UIImage? { + guard let url = Bundle.module.url( + forResource: resourceName, + withExtension: "png" + ) else { return nil } + return UIImage(contentsOfFile: url.path) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swift index 3543a9cc3ee6..19851579eb4b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swift @@ -24,7 +24,7 @@ struct OnboardingSceneChrome: Equatable { defaultValue: "Continue" ) secondaryTitle = nil - case .notifications: + case .notifications, .pushNotifications: primaryTitle = L10n.string( "mobile.onboarding.continue", defaultValue: "Continue" diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingStage.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingStage.swift index d1f182fb92a8..557266f042b1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingStage.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingStage.swift @@ -2,6 +2,7 @@ enum OnboardingStage: Int, CaseIterable, Hashable, Sendable { case agents case notifications + case pushNotifications case connect var position: Int { rawValue + 1 } @@ -10,6 +11,7 @@ enum OnboardingStage: Int, CaseIterable, Hashable, Sendable { switch self { case .agents: "agents" case .notifications: "notifications" + case .pushNotifications: "push_notifications" case .connect: "connect" } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/OnboardingScreenshots/Onboarding-push-en.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/OnboardingScreenshots/Onboarding-push-en.png new file mode 100644 index 000000000000..0dc65eb0991a Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/OnboardingScreenshots/Onboarding-push-en.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/OnboardingScreenshots/Onboarding-push-ja.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/OnboardingScreenshots/Onboarding-push-ja.png new file mode 100644 index 000000000000..37a05fc97dbb Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/OnboardingScreenshots/Onboarding-push-ja.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ScreenshotNotificationPresenter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ScreenshotNotificationPresenter.swift index dcdb8a9b25a1..8253b2473a3c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ScreenshotNotificationPresenter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ScreenshotNotificationPresenter.swift @@ -1,7 +1,7 @@ #if canImport(UIKit) && DEBUG -import UserNotifications +@preconcurrency import UserNotifications -/// Drives a real iOS notification for the App Store notifications screenshot. +/// Drives a realistic set of iOS notifications for screenshot capture. /// /// Safety: `UNUserNotificationCenter` retains the delegate and may call it from /// framework-managed concurrency contexts; this object guards its only mutable @@ -16,24 +16,67 @@ final class ScreenshotNotificationPresenter: NSObject, UNUserNotificationCenterD center.delegate = self center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in guard granted else { return } - let content = UNMutableNotificationContent() - content.title = String( - localized: "mobile.screenshot.notification.title", - defaultValue: "Agent needs your input", - bundle: .main - ) - content.body = String( - localized: "mobile.screenshot.notification.body", - defaultValue: "Claude is asking: which database should I use, Postgres or SQLite?", - bundle: .main - ) - content.sound = .default - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.6, repeats: false) - center.add(UNNotificationRequest( - identifier: "cmux-screenshot-agent", - content: content, - trigger: trigger - )) + center.removeAllDeliveredNotifications() + center.removeAllPendingNotificationRequests() + + let notifications = [ + ( + identifier: "cmux-screenshot-input", + title: String( + localized: "mobile.screenshot.notification.input.title", + defaultValue: "Codex needs your input", + bundle: .main + ), + body: String( + localized: "mobile.screenshot.notification.input.body", + defaultValue: "Approve the command to keep work moving.", + bundle: .main + ) + ), + ( + identifier: "cmux-screenshot-finished", + title: String( + localized: "mobile.screenshot.notification.finished.title", + defaultValue: "Claude finished", + bundle: .main + ), + body: String( + localized: "mobile.screenshot.notification.finished.body", + defaultValue: "The login crash fix is ready for review.", + bundle: .main + ) + ), + ( + identifier: "cmux-screenshot-tests", + title: String( + localized: "mobile.screenshot.notification.tests.title", + defaultValue: "Tests need attention", + bundle: .main + ), + body: String( + localized: "mobile.screenshot.notification.tests.body", + defaultValue: "2 checks failed in cmux. Tap to open the workspace.", + bundle: .main + ) + ), + ] + + for (index, notification) in notifications.enumerated() { + let content = UNMutableNotificationContent() + content.title = notification.title + content.body = notification.body + content.sound = .default + content.threadIdentifier = "cmux-screenshot-agent-events" + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: 0.7 * Double(index + 1), + repeats: false + ) + center.add(UNNotificationRequest( + identifier: notification.identifier, + content: content, + trigger: trigger + )) + } } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 78cc7f9f3059..1feb0b3ce313 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -16849,36 +16849,104 @@ } } }, - "mobile.screenshot.notification.body": { + "mobile.screenshot.notification.finished.body": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Claude is asking: which database should I use, Postgres or SQLite?" + "value": "The login crash fix is ready for review." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Claude が質問しています: どのデータベースを使いますか、Postgres それとも SQLite?" + "value": "ログインクラッシュの修正をレビューできます。" } } } }, - "mobile.screenshot.notification.title": { + "mobile.screenshot.notification.finished.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Agent needs your input" + "value": "Claude finished" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude が完了しました" + } + } + } + }, + "mobile.screenshot.notification.input.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Approve the command to keep work moving." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドを承認して作業を続けてください。" + } + } + } + }, + "mobile.screenshot.notification.input.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex needs your input" } }, "ja": { "stringUnit": { "state": "translated", - "value": "エージェントが入力を求めています" + "value": "Codex が入力を求めています" + } + } + } + }, + "mobile.screenshot.notification.tests.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "2 checks failed in cmux. Tap to open the workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux で 2 件のチェックが失敗しました。タップしてワークスペースを開きます。" + } + } + } + }, + "mobile.screenshot.notification.tests.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tests need attention" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テストを確認してください" } } } @@ -19195,6 +19263,57 @@ } } }, + "mobile.onboarding.pushNotifications.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Get a push when work finishes or needs your input. Tap to open the right workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作業が完了したときや入力が必要なときにプッシュ通知を受け取れます。タップすると該当のワークスペースに移動します。" + } + } + } + }, + "mobile.onboarding.pushNotifications.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Know when your agent needs you" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントがあなたを必要としたら通知" + } + } + } + }, + "mobile.onboarding.pushPreview.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Three cmux notifications for completed work, failed checks, and requested input." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了した作業、失敗したチェック、入力要求を知らせる3件のcmux通知。" + } + } + } + }, "mobile.onboarding.preview.activeAgents": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxUITests/SnapshotUITests.swift b/ios/cmuxUITests/SnapshotUITests.swift index 8c12dae5d2cb..e252edd7985e 100644 --- a/ios/cmuxUITests/SnapshotUITests.swift +++ b/ios/cmuxUITests/SnapshotUITests.swift @@ -26,9 +26,9 @@ final class SnapshotUITests: XCTestCase { "CMUX_UITEST_WORKSPACE_LIST_PREVIEW": "1", ]) - // 2) A REAL agent push notification over the workspace list: the app - // requests authorization and schedules a genuine local notification, so - // the system renders the actual banner (real icon, "cmux" display name). + // 2) Real agent push notifications over the workspace list: the app + // requests authorization and schedules genuine local notifications, so + // the system renders the actual banner (real icon and app display name). shoot("02-Notifications", [ "CMUX_UITEST_WORKSPACE_LIST_PREVIEW": "1", "CMUX_UITEST_NOTIFICATION_BANNER": "1", @@ -109,8 +109,8 @@ final class SnapshotUITests: XCTestCase { break } } - // The scheduled local notification fires ~0.6s after the grant and the - // foreground banner is on screen for ~5s. Querying the banner element is + // The three local notifications finish firing ~2.1s after the grant and + // the last foreground banner remains on screen. Querying its element is // unreliable (it's a system-process overlay, and waiting past its // lifetime captures an empty screen), so snapshot at a fixed time inside // the banner's visible window. diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 8422422ce1a8..1d9fb2900a65 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -62,13 +62,13 @@ final class cmuxUITests: XCTestCase { /// durable progress key to `welcome`; advancing to Connect writes the real /// `.connect` milestone. The default connection scene must describe /// same-account automatic discovery without presenting QR as the primary - /// path. The first product scene uses the shipped workspace-list capture, - /// while the notification scene shows the shipped chronological feed. The + /// path. The first two product scenes use the shipped workspace-list and + /// notification-feed captures, followed by the system push preview. The /// connection scene keeps its live connection-state illustration. Relaunching /// after the simulated search finishes must resume at Connect without /// exposing manual pairing until Tailscale is selected. @MainActor - func testOnboardingScenesNotificationFeedResumeAndTailscaleScanner() throws { + func testOnboardingScenesNotificationFeedPushPreviewResumeAndTailscaleScanner() throws { let app = XCUIApplication() XCUIDevice.shared.orientation = .portrait let baseArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] @@ -247,7 +247,7 @@ final class cmuxUITests: XCTestCase { visual: notificationsScreenshot, additionalContent: [notificationsBody] ) - capture("onboarding-02-notifications") + capture("onboarding-02-notification-feed") let backButton = app.buttons["MobileOnboardingBackButton"] backButton.tap() @@ -266,6 +266,31 @@ final class cmuxUITests: XCTestCase { primaryButton.tap() + let pushScene = element("MobileOnboardingPushNotificationsScene") + assertPageVisible(pushScene) + XCTAssertTrue(app.staticTexts["Know when your agent needs you"].exists) + let pushBody = app.staticTexts.matching(NSPredicate( + format: "label == %@", + "Get a push when work finishes or needs your input. Tap to open the right workspace." + )).firstMatch + XCTAssertTrue(pushBody.exists) + let pushPreview = element("MobileOnboardingPushPreview") + XCTAssertTrue(pushPreview.exists) + XCTAssertFalse(element("MobileOnboardingPushBanner").exists) + assertStableChrome() + assertPageContentFitsWithoutScrolling( + title: app.staticTexts["Know when your agent needs you"], + visual: pushPreview, + additionalContent: [pushBody] + ) + capture("onboarding-03-push-notifications") + + backButton.tap() + assertPageVisible(notificationsScene) + primaryButton.tap() + assertPageVisible(pushScene) + primaryButton.tap() + let connectScene = element("MobileOnboardingConnectScene") assertPageVisible(connectScene) XCTAssertTrue(app.staticTexts["Your Mac connects automatically"].exists) @@ -286,7 +311,7 @@ final class cmuxUITests: XCTestCase { ]], includeFooter: false ) - capture("onboarding-03-connect") + capture("onboarding-04-connect") // Drop only the launch-domain override. The application-domain value // written while entering Connect must now be the source of truth. The @@ -343,7 +368,7 @@ final class cmuxUITests: XCTestCase { ], includeFooter: true ) - capture("onboarding-04-resumed-connect") + capture("onboarding-05-resumed-connect") scanPairingCodeButton.tap() @@ -357,12 +382,12 @@ final class cmuxUITests: XCTestCase { "On cmux 0.64.17, choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open Tailscale Pairing. Install Tailscale on both devices and use the same Tailscale network first." ) XCTAssertTrue(scannerCancel.waitForExistence(timeout: 4)) - capture("onboarding-05-scanner-fallback") + capture("onboarding-06-scanner-fallback") scannerCancel.tap() XCTAssertTrue(connectScene.waitForExistence(timeout: 4)) XCTAssertTrue(scannerPreview.waitForNonExistence(timeout: 2)) - capture("onboarding-06-scanner-cancelled") + capture("onboarding-07-scanner-cancelled") tap(automaticMethod, in: app) XCTAssertTrue(app.staticTexts["Your Mac connects automatically"].waitForExistence(timeout: 4)) @@ -382,7 +407,7 @@ final class cmuxUITests: XCTestCase { visual: agentsScreenshot, additionalContent: [agentsBody] ) - capture("onboarding-07-agents-compact-height") + capture("onboarding-08-agents-compact-height") primaryButton.tap() assertPageVisible(notificationsScene) @@ -391,7 +416,16 @@ final class cmuxUITests: XCTestCase { visual: notificationsScreenshot, additionalContent: [notificationsBody] ) - capture("onboarding-08-notifications-compact-height") + capture("onboarding-09-notification-feed-compact-height") + + primaryButton.tap() + assertPageVisible(pushScene) + assertPageContentFitsWithoutScrolling( + title: app.staticTexts["Know when your agent needs you"], + visual: pushPreview, + additionalContent: [pushBody] + ) + capture("onboarding-10-push-notifications-compact-height") primaryButton.tap() assertPageVisible(connectScene) @@ -413,7 +447,7 @@ final class cmuxUITests: XCTestCase { element("MobileOnboardingConnectionMethodPicker"), ] ) - capture("onboarding-09-connect-compact-height") + capture("onboarding-11-connect-compact-height") } /// Manual pairing only authorizes a Tailscale route, so Auto-Connect must