From a03397ad223df1bb64599b51dd0a5ce6a57aed5f Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 14 Oct 2026 18:17:08 +0800 Subject: [PATCH] feat(review): replace automatic prompts with inline invitations --- FrameworkTests/AppReviewControllerTests.swift | 386 ++++++------------ Frameworks/Resources/Localizable.xcstrings | 287 +++++++++++++ .../FeatureModels/AppReviewController.swift | 227 ++++------ .../TermindApplication/SettingsKey.swift | 6 +- .../AppReviewDeveloperSection.swift | 24 ++ .../AppReviewInvitationView.swift | 104 +++++ .../Strings/AppReview.swift | 15 + TFI/App/MobileAppReviewPresenter.swift | 66 --- TFI/App/MobileFeatureRegistry.swift | 4 +- TFI/App/MobileRootView.swift | 2 - TFI/App/MobileSceneModel.swift | 9 - TFI/Features/Ports/MobileForwardingView.swift | 5 +- .../Settings/MobileAppSettingsView.swift | 11 +- .../Settings/MobileSettingsView.swift | 11 +- TFI/Features/Vault/MobileVaultView.swift | 32 +- .../MobileAppReviewWindowReader.swift | 261 ------------ TFITests/MobileAppReviewTests.swift | 107 ++--- TFITests/MobileForwardingRenderingTests.swift | 2 +- TFM/App/AppReviewPresenter.swift | 45 -- TFM/App/RootView.swift | 1 - TFM/Features/Hosts/HostsPageView.swift | 19 + .../Settings/DeveloperSettingsPage.swift | 1 + TFM/Features/Settings/SettingsPageView.swift | 9 +- TFM/Platform/AppReviewWindowReader.swift | 144 ------- TFM/UI/Managed/CardGrid.swift | 2 +- TFM/UI/Managed/ManagedCardPage.swift | 35 +- TFMTests/AppReviewControllerTests.swift | 76 ++-- 27 files changed, 799 insertions(+), 1092 deletions(-) create mode 100644 Frameworks/TermindPresentation/AppReviewDeveloperSection.swift create mode 100644 Frameworks/TermindPresentation/AppReviewInvitationView.swift create mode 100644 Frameworks/TermindPresentation/Strings/AppReview.swift delete mode 100644 TFI/App/MobileAppReviewPresenter.swift delete mode 100644 TFI/Platform/MobileAppReviewWindowReader.swift delete mode 100644 TFM/App/AppReviewPresenter.swift delete mode 100644 TFM/Platform/AppReviewWindowReader.swift diff --git a/FrameworkTests/AppReviewControllerTests.swift b/FrameworkTests/AppReviewControllerTests.swift index 411d0ce4..9294794a 100644 --- a/FrameworkTests/AppReviewControllerTests.swift +++ b/FrameworkTests/AppReviewControllerTests.swift @@ -3,303 +3,171 @@ import Testing @testable import TermindApplication -@Suite -@MainActor +@Suite @MainActor struct AppReviewControllerTests { @Test - func firstUseRequiresSevenElapsedDaysAndThreeDistinctActiveDates() throws { + func startsAtFirstLaunchAndRequiresSevenElapsedDaysAndAHost() throws { let h = try Harness() defer { h.clear() } - h.update(.reviewPage("vault.hosts")) - h.advance(86_400) - h.update(.reviewPage("vault.hosts")) - h.update(.reviewPage("vault.hosts")) - #expect(h.activeDays == 2) - h.date = h.firstDate.addingTimeInterval(7 * 86_400 - 301) - h.work(for: 300) - h.finish() - #expect(h.requests == 0) - #expect(h.activeDays == 3) - h.date = h.firstDate.addingTimeInterval(7 * 86_400) - h.work(for: 0) - h.finish() - #expect(h.requests == 1) + let controller = h.makeController() + #expect(h.defaults.object(forKey: SettingsKey.reviewFirstUse) as? Date == h.date) + h.date.addTimeInterval(7 * 86_400 - 1) + #expect(!controller.isEligible(hasHosts: true)) + h.date.addTimeInterval(1) + #expect(controller.isEligible(hasHosts: true)) + #expect(!controller.isEligible(hasHosts: false)) } @Test - func calendarAgeAloneDoesNotReplaceActiveDays() throws { + func reopeningPreservesFirstUseAndIgnoresLegacyAttemptBudgets() throws { let h = try Harness() defer { h.clear() } - h.update(.reviewPage("vault.hosts")) - h.advance(8 * 86_400) - h.work(for: 300) - h.finish() - #expect(h.activeDays == 2) - #expect(h.requests == 0) + _ = h.makeController() + let firstUse = h.date + h.date.addTimeInterval(8 * 86_400) + h.defaults.set(h.date, forKey: "app.review.lastAttempt") + h.defaults.set(["1.0"], forKey: "app.review.requestedVersions") + h.defaults.set(["one-day"], forKey: "app.review.activeDays") + let controller = h.makeController() + #expect(controller.isEligible(hasHosts: true)) + #expect(h.defaults.object(forKey: SettingsKey.reviewFirstUse) as? Date == firstUse) + } + + @Test(arguments: [false, true]) + func bothResponsesSurviveRelaunchAndDoNotClaimACompletedRating(opened: Bool) throws { + let h = try Harness() + defer { h.clear() } + let controller = h.makeController() + h.date.addTimeInterval(8 * 86_400) + if opened { controller.openReview { _, completion in completion(true) } } else { controller.decline() } + h.date.addTimeInterval(366 * 86_400) + let restored = h.makeController() + #expect(restored.response == (opened ? .openedReview : .declined)) + #expect(!restored.isEligible(hasHosts: true)) } @Test - func fiveMinutesOnlyCountsForegroundWorkspaceTime() throws { + func aRejectedURLOpenDoesNotConsumeTheInvitation() throws { let h = try Harness() defer { h.clear() } - h.mature() - h.work(for: 299) - h.update(.workspace(h.workspaceID), active: false) - h.advance(3_600) - h.update(.workspace(h.workspaceID)) - h.finish() - #expect(h.requests == 0) - h.work(for: 1) - h.finish() - #expect(h.requests == 1) + let controller = h.makeController() + h.date.addTimeInterval(8 * 86_400) + controller.openReview { _, completion in completion(false) } + #expect(controller.response == nil) + #expect(controller.isEligible(hasHosts: true)) + #expect(h.defaults.object(forKey: SettingsKey.reviewInvitationResponse) == nil) } @Test - func eligibleReturnWaitsThreeSecondsAndRecordsBeforeCallingStoreKit() throws { + func timeAndNewHostsDoNotInsertAnInvitationUntilTheNextVisit() throws { let h = try Harness() defer { h.clear() } - h.mature() - h.work(for: 300) - h.update(.reviewPage("settings")) - h.advance(2.999) - h.request() - #expect(h.requests == 0) - h.advance(0.001) - h.controller.requestIfReady(surfaceID: h.surfaceID) { - #expect(h.defaults.stringArray(forKey: SettingsKey.reviewRequestedVersions) == ["1.0"]) - #expect(h.defaults.object(forKey: SettingsKey.reviewLastAttempt) as? Date == h.date) - h.requests += 1 + let controller = h.makeController() + var visit = AppReviewInvitationVisit() + visit.enter(controller: controller, hasHosts: true) + h.date.addTimeInterval(8 * 86_400) + #expect(!visit.isVisible(controller: controller, hasHosts: true, isFiltering: false)) + visit.enter(controller: controller, hasHosts: false) + #expect(!visit.isVisible(controller: controller, hasHosts: true, isFiltering: false)) + visit.enter(controller: controller, hasHosts: true) + #expect(visit.isVisible(controller: controller, hasHosts: true, isFiltering: false)) + } + + @Test(arguments: [false, true]) + func allWindowsHideImmediatelyAfterEitherResponse(opened: Bool) throws { + let h = try Harness() + defer { h.clear() } + let controller = h.makeController() + h.date.addTimeInterval(8 * 86_400) + var first = AppReviewInvitationVisit() + var second = AppReviewInvitationVisit() + first.enter(controller: controller, hasHosts: true) + second.enter(controller: controller, hasHosts: true) + #expect(first.isVisible(controller: controller, hasHosts: true, isFiltering: false)) + #expect(second.isVisible(controller: controller, hasHosts: true, isFiltering: false)) + if opened { controller.openReview { _, completion in completion(true) } } else { controller.decline() } + #expect(!first.isVisible(controller: controller, hasHosts: true, isFiltering: false)) + #expect(!second.isVisible(controller: controller, hasHosts: true, isFiltering: false)) + } + + @Test + func filteringAndDeletingTheLastHostHideWithoutConsumingTheInvitation() throws { + let h = try Harness() + defer { h.clear() } + let controller = h.makeController() + h.date.addTimeInterval(8 * 86_400) + var visit = AppReviewInvitationVisit() + visit.enter(controller: controller, hasHosts: true) + #expect(!visit.isVisible(controller: controller, hasHosts: true, isFiltering: true)) + #expect(!visit.isVisible(controller: controller, hasHosts: false, isFiltering: false)) + #expect(visit.isVisible(controller: controller, hasHosts: true, isFiltering: false)) + #expect(controller.response == nil) + } + + @Test(arguments: [false, true]) + func developerSwitchBypassesGatesAndNeverChangesTheRealResponse(opened: Bool) throws { + let h = try Harness() + defer { h.clear() } + let controller = h.makeController() + controller.decline() + var visit = AppReviewInvitationVisit() + visit.enter(controller: controller, hasHosts: false) + controller.setPreviewEnabled(true) + #expect(visit.isVisible(controller: controller, hasHosts: false, isFiltering: false)) + #expect(!visit.isVisible(controller: controller, hasHosts: false, isFiltering: true)) + if opened { + controller.openReview { _, _ in Issue.record("Developer preview must not open external URLs.") } + } else { + controller.decline() } - h.request() - #expect(h.requests == 1) - } - - enum Cancellation: CaseIterable { - case interaction, navigation, blocked, focus, removal, anotherSurface - } - - @Test(arguments: Cancellation.allCases) - func cancelledInvitationsWaitForAnotherWorkspaceReturn(_ reason: Cancellation) throws { - let h = try Harness() - defer { h.clear() } - h.mature() - h.work(for: 300) - h.update(.reviewPage("settings")) - switch reason { - case .interaction: h.controller.recordInteraction(surfaceID: h.surfaceID) - case .navigation: h.update(.reviewPage("settings.app")) - case .blocked: h.update(.reviewPage("settings"), blocked: true) - case .focus: h.update(.reviewPage("settings"), active: false) - case .removal: h.controller.removeSurface(h.surfaceID) - case .anotherSurface: - h.controller.update( - surfaceID: UUID(), context: .init(location: .reviewPage("settings"), isActive: true) - ) - } - h.advance(10) - h.update(.reviewPage("settings")) - h.request() - #expect(h.requests == 0) - h.work(for: 0) - h.finish() - #expect(h.requests == 1) + #expect(!visit.isVisible(controller: controller, hasHosts: false, isFiltering: false)) + #expect(controller.response == .declined) + #expect(h.makeController().response == .declined) + #expect(!h.makeController().isPreviewEnabled) + controller.setPreviewEnabled(false) + controller.setPreviewEnabled(true) + #expect(visit.isVisible(controller: controller, hasHosts: false, isFiltering: false)) + controller.setPreviewEnabled(false) + #expect(!visit.isVisible(controller: controller, hasHosts: false, isFiltering: false)) } @Test - func blockedReturnAndEditorExitDoNotArmRequests() throws { + func disabledControllersLeaveDefaultsUntouched() throws { let h = try Harness() defer { h.clear() } - h.mature() - h.work(for: 300) - h.update(.reviewPage("settings"), blocked: true) - h.advance(10) - h.update(.reviewPage("settings")) - h.request() - #expect(h.requests == 0) - h.work(for: 0) - h.update(.other) - h.advance(10) - h.update(.reviewPage("settings")) - h.request() - #expect(h.requests == 0) - } - - @Test - func multipleSurfacesShareTimeAndCannotIssueDuplicateRequests() throws { - let h = try Harness() - defer { h.clear() } - h.mature() - h.work(for: 150) - let secondSurface = UUID() - h.controller.update( - surfaceID: secondSurface, context: .init(location: .workspace(UUID()), isActive: true) - ) - h.advance(149) - h.controller.update( - surfaceID: secondSurface, context: .init(location: .reviewPage("settings"), isActive: true) - ) - h.advance(3) - h.controller.requestIfReady(surfaceID: secondSurface) { h.requests += 1 } - #expect(h.requests == 0) - h.work(for: 1) - h.finish() - #expect(h.requests == 1) - h.controller.update( - surfaceID: secondSurface, context: .init(location: .workspace(UUID()), isActive: true) - ) - h.controller.update( - surfaceID: secondSurface, context: .init(location: .reviewPage("settings"), isActive: true) - ) - h.advance(3) - h.controller.requestIfReady(surfaceID: secondSurface) { h.requests += 1 } - #expect(h.requests == 1) - } - - @Test - func persistedVersionBudgetAndCooldownSurviveRelaunchWithoutRestoringWorkspaceTime() throws { - let h = try Harness() - defer { h.clear() } - h.mature() - h.work(for: 300) - h.finish() - let firstAttempt = h.date - h.controller = h.makeController(version: "1.1") - h.date = firstAttempt.addingTimeInterval(120 * 86_400 - 1) - h.work(for: 0) - h.update(.reviewPage("settings")) - h.advance(3, wallTime: false) - h.request() - #expect(h.requests == 1) - h.work(for: 300, wallTime: false) - h.update(.reviewPage("settings")) - h.advance(3, wallTime: false) - h.request() - #expect(h.requests == 1) - h.date = firstAttempt.addingTimeInterval(120 * 86_400) - h.work(for: 0) - h.finish() - #expect(h.requests == 2) - h.controller = h.makeController(version: "1.0") - h.advance(121 * 86_400) - h.work(for: 300) - h.finish() - #expect(h.requests == 2) - } - - @Test - func manualLinkCancelsPendingRequestAndRestartsCooldownWithoutClaimingARating() throws { - let h = try Harness() - defer { h.clear() } - h.mature() - h.work(for: 300) - h.update(.reviewPage("settings.app")) - h.controller.recordManualReview() - h.advance(3) - h.request() - #expect(h.requests == 0) - #expect(h.defaults.object(forKey: SettingsKey.reviewRequestedVersions) == nil) - h.work(for: 0) - h.finish() - #expect(h.requests == 0) - h.advance(120 * 86_400) - h.work(for: 0) - h.finish() - #expect(h.requests == 1) - } - - @Test - func wallClockRollbackDoesNotIncreaseWorkspaceTimeOrBypassCooldown() throws { - let h = try Harness() - defer { h.clear() } - h.mature() - h.work(for: 299) - h.date = h.date.addingTimeInterval(86_400) - h.finish() - #expect(h.requests == 0) - h.work(for: 1) - h.finish() - #expect(h.requests == 1) - h.controller = h.makeController(version: "1.1") - h.date = h.firstDate - h.work(for: 300) - h.finish() - #expect(h.requests == 1) - } - - @Test - func disabledControllersLeaveUsageUntouched() throws { - let h = try Harness() - defer { h.clear() } - h.controller = AppReviewController(defaults: h.defaults, version: "1.0") - h.mature() - h.work(for: 300) - h.finish() - #expect(h.requests == 0) + let controller = AppReviewController(defaults: h.defaults, now: { h.date }) + h.date.addTimeInterval(8 * 86_400) + controller.decline() + controller.openReview { _, completion in completion(true) } + #expect(!controller.isEligible(hasHosts: true)) #expect(h.defaults.object(forKey: SettingsKey.reviewFirstUse) == nil) - #expect(h.activeDays == 0) + #expect(h.defaults.object(forKey: SettingsKey.reviewInvitationResponse) == nil) + } + + @Test + func clockRollbackDoesNotMakeANewInstallationEligible() throws { + let h = try Harness() + defer { h.clear() } + let controller = h.makeController() + h.date.addTimeInterval(-86_400) + #expect(!controller.isEligible(hasHosts: true)) } private final class Harness { - let suite = "AppReviewControllerTests.\(UUID().uuidString)" + let suite = "AppReviewControllerTests.\(UUID())" let defaults: UserDefaults - let firstDate = Date(timeIntervalSince1970: 1_735_689_600) var date = Date(timeIntervalSince1970: 1_735_689_600) - var tick: TimeInterval = 0 - let surfaceID = UUID() - let workspaceID = UUID() - var requests = 0 - var controller = AppReviewController() init() throws { defaults = try #require(UserDefaults(suiteName: suite)) - controller = makeController(version: "1.0") } - func makeController(version: String) -> AppReviewController { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(secondsFromGMT: 0)! - return AppReviewController( - defaults: defaults, version: version, isEnabled: true, - now: { [unowned self] in self.date }, - uptime: { [unowned self] in self.tick }, calendar: calendar - ) + func makeController() -> AppReviewController { + AppReviewController(defaults: defaults, isEnabled: true, now: { [unowned self] in self.date }) } - var activeDays: Int { defaults.stringArray(forKey: SettingsKey.reviewActiveDays)?.count ?? 0 } - - func clear() { defaults.removePersistentDomain(forName: suite) } - - func advance(_ seconds: TimeInterval, wallTime: Bool = true) { - tick += seconds - if wallTime { date = date.addingTimeInterval(seconds) } + func clear() { + defaults.removePersistentDomain(forName: suite) } - - func update(_ location: AppReviewController.Location, active: Bool = true, blocked: Bool = false) { - controller.update( - surfaceID: surfaceID, - context: .init(location: location, isActive: active, isBlocked: blocked) - ) - } - - func mature() { - update(.reviewPage("vault.hosts")) - advance(86_400) - update(.reviewPage("vault.hosts")) - advance(6 * 86_400) - update(.reviewPage("vault.hosts")) - } - - func work(for seconds: TimeInterval, wallTime: Bool = true) { - update(.workspace(workspaceID)) - advance(seconds, wallTime: wallTime) - } - - func finish() { - update(.reviewPage("settings")) - advance(3) - request() - } - - func request() { controller.requestIfReady(surfaceID: surfaceID) { requests += 1 } } } } diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 9cc077a0..b1c36c28 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -26531,6 +26531,47 @@ } } }, + "Enjoying Termind?" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gefällt dir Termind?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous aimez Termind ?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termindを気に入っていただけましたか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind가 마음에 드시나요?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "喜欢Termind吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "喜歡Termind嗎?" + } + } + } + }, "Enter Sends" : { "extractionState" : "manual", "localizations" : { @@ -43398,6 +43439,47 @@ } } }, + "No, thanks" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nein, danke" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non, merci" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "結構です" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "괜찮아요" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不用了" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "不用了" + } + } + } + }, "None" : { "extractionState" : "manual", "localizations" : { @@ -55626,6 +55708,47 @@ } } }, + "Review Invitation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewertungsanfrage" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invitation à laisser un avis" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レビューのお願い" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "평가 요청" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "评价邀请" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "評價邀請" + } + } + } + }, "Review and accept the import warnings." : { "extractionState" : "manual", "localizations" : { @@ -60933,6 +61056,47 @@ } } }, + "Share your experience on the App Store." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teile deine Erfahrungen im App Store." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partagez votre expérience sur l’App Store." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Storeで使い心地をお聞かせください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store에서 사용 경험을 공유해 주세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "欢迎在App Store分享你的使用感受。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "歡迎在App Store分享你的使用感受。" + } + } + } + }, "Shell Integration" : { "extractionState" : "manual", "localizations" : { @@ -61312,6 +61476,47 @@ } } }, + "Show Review Invitation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewertungsanfrage anzeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l’invitation à laisser un avis" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レビュー依頼を表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "평가 요청 표시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示评价邀请" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示評價邀請" + } + } + } + }, "Show Secret" : { "extractionState" : "manual", "localizations" : { @@ -61435,6 +61640,47 @@ } } }, + "Show a test invitation on the hosts list. It does not open the App Store or change your review preference." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeigt eine Testanfrage in der Hostliste an. Sie öffnet den App Store nicht und ändert deine Bewertungseinstellung nicht." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affiche une invitation de test dans la liste des hôtes, sans ouvrir l’App Store ni modifier votre préférence d’évaluation." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ホスト一覧にテスト用の依頼を表示します。App Storeを開いたり、レビューの設定を変更したりすることはありません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "호스트 목록에 테스트 요청을 표시합니다. App Store를 열거나 평가 설정을 변경하지 않습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在主机列表显示测试邀请,不会打开App Store或更改你的评价偏好。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在主機列表顯示測試邀請,不會開啟App Store或更改你的評價偏好。" + } + } + } + }, "Show details" : { "extractionState" : "manual", "localizations" : { @@ -81976,6 +82222,47 @@ } } }, + "Yes" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oui" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "はい" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "네" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "是的" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "是的" + } + } + } + }, "You have unsaved changes. Save them before leaving, or discard them." : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/FeatureModels/AppReviewController.swift b/Frameworks/TermindApplication/FeatureModels/AppReviewController.swift index e49db98e..89b50f5d 100644 --- a/Frameworks/TermindApplication/FeatureModels/AppReviewController.swift +++ b/Frameworks/TermindApplication/FeatureModels/AppReviewController.swift @@ -1,177 +1,96 @@ import Foundation +import Observation -/// One invitation budget for every app surface. StoreKit reports neither -/// presentation nor completion, so persisted records describe attempts, never ratings. -@MainActor +/// Device-local invitation state, shared by all windows. Opening the store +/// records an intent to review, never a completed rating. +@MainActor @Observable public final class AppReviewController { public static let reviewURL = URL(string: "https://apps.apple.com/app/id6805856690?action=write-review")! - public enum Location: Equatable { - case workspace(UUID) - case reviewPage(String) - case other - - var isWorkspace: Bool { - if case .workspace = self { return true } - return false - } - - var isReviewPage: Bool { - if case .reviewPage = self { return true } - return false - } + public enum Response: String { + case declined + case openedReview } - public struct Context: Equatable { - public var location: Location - public var isActive: Bool - public var isBlocked: Bool - - public init(location: Location, isActive: Bool, isBlocked: Bool = false) { - self.location = location - self.isActive = isActive - self.isBlocked = isBlocked - } - - var canRequest: Bool { isActive && !isBlocked && location.isReviewPage } - } - - private struct Pending { - let surfaceID: UUID - let deadline: TimeInterval - } - - private let defaults: UserDefaults? - private let version: String - private let now: () -> Date - private let uptime: () -> TimeInterval - private let calendar: Calendar + public private(set) var response: Response? + public private(set) var isPreviewEnabled = false + public private(set) var previewResponse: Response? public let isEnabled: Bool - private var contexts: [UUID: Context] = [:] - private var activeSurfaceID: UUID? - private var lastTick: TimeInterval? - private var workspaceSeconds: TimeInterval = 0 - private var pending: Pending? + private let defaults: UserDefaults? + private let now: () -> Date + private let firstUse: Date public init( - defaults: UserDefaults? = nil, version: String = "", - isEnabled: Bool = false, now: @escaping () -> Date = Date.init, - uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, - calendar: Calendar = .autoupdatingCurrent + defaults: UserDefaults? = nil, isEnabled: Bool = false, + now: @escaping () -> Date = Date.init ) { self.defaults = defaults - self.version = version self.isEnabled = isEnabled self.now = now - self.uptime = uptime - self.calendar = calendar + firstUse = defaults?.object(forKey: SettingsKey.reviewFirstUse) as? Date ?? now() + response = defaults?.string(forKey: SettingsKey.reviewInvitationResponse).flatMap(Response.init(rawValue:)) + if isEnabled, defaults?.object(forKey: SettingsKey.reviewFirstUse) == nil { + defaults?.set(firstUse, forKey: SettingsKey.reviewFirstUse) + } } public static func live() -> AppReviewController { let isTesting = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil - return AppReviewController( - defaults: isTesting ? nil : .standard, - version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "", - isEnabled: !isTesting - ) + return AppReviewController(defaults: isTesting ? nil : .standard, isEnabled: !isTesting) } - /// Called on navigation, native activity changes and foreground timer ticks. - /// Only a workspace-to-list transition can arm a request; becoming eligible, - /// dismissing a sheet, or regaining focus alone never does. - public func update(surfaceID: UUID, context: Context) { + public func isEligible(hasHosts: Bool) -> Bool { + isEnabled && response == nil && hasHosts && now().timeIntervalSince(firstUse) >= 7 * 86_400 + } + + public func setPreviewEnabled(_ enabled: Bool) { + isPreviewEnabled = enabled + previewResponse = nil + } + + public func decline() { + setResponse(.declined) + } + + public func openReview(using open: (URL, @escaping (Bool) -> Void) -> Void) { + if isPreviewEnabled { + previewResponse = .openedReview + return + } + open(Self.reviewURL) { [weak self] accepted in + guard let self, accepted, self.isEnabled else { return } + self.response = .openedReview + self.defaults?.set(Response.openedReview.rawValue, forKey: SettingsKey.reviewInvitationResponse) + } + } + + private func setResponse(_ response: Response) { + if isPreviewEnabled { + previewResponse = response + return + } guard isEnabled else { return } - accountForWorkspaceTime() - let previous = contexts[surfaceID] - contexts[surfaceID] = context - - if context.isActive { - if activeSurfaceID != surfaceID { - pending = nil - activeSurfaceID = surfaceID - } - recordActiveDay() - } else if activeSurfaceID == surfaceID { - activeSurfaceID = nil - } - - if pending?.surfaceID == surfaceID, - !context.canRequest || previous?.location != context.location - { - pending = nil - } - if previous?.isActive == true, previous?.location.isWorkspace == true, - context.canRequest, isEligible - { - pending = Pending(surfaceID: surfaceID, deadline: uptime() + 3) - } - } - - public func recordInteraction(surfaceID: UUID) { - accountForWorkspaceTime() - pending = nil - if contexts[surfaceID]?.isActive == true { activeSurfaceID = surfaceID } - } - - public func removeSurface(_ surfaceID: UUID) { - accountForWorkspaceTime() - contexts.removeValue(forKey: surfaceID) - if activeSurfaceID == surfaceID { activeSurfaceID = nil } - recordInteraction(surfaceID: surfaceID) - } - - public func requestIfReady(surfaceID: UUID, request: () -> Void) { - guard let pending, pending.surfaceID == surfaceID, - uptime() >= pending.deadline, activeSurfaceID == surfaceID, - contexts[surfaceID]?.canRequest == true, isEligible - else { return } - self.pending = nil - var versions = defaults?.stringArray(forKey: SettingsKey.reviewRequestedVersions) ?? [] - versions.append(version) - defaults?.set(versions, forKey: SettingsKey.reviewRequestedVersions) - defaults?.set(now(), forKey: SettingsKey.reviewLastAttempt) - request() - } - - public func recordManualReview() { - pending = nil - defaults?.set(now(), forKey: SettingsKey.reviewLastAttempt) - } - - private var isEligible: Bool { - guard isEnabled, !version.isEmpty, workspaceSeconds >= 300, - let firstUse = defaults?.object(forKey: SettingsKey.reviewFirstUse) as? Date, - now().timeIntervalSince(firstUse) >= 7 * 86_400, - (defaults?.stringArray(forKey: SettingsKey.reviewActiveDays)?.count ?? 0) >= 3, - !(defaults?.stringArray(forKey: SettingsKey.reviewRequestedVersions) ?? []).contains(version) - else { return false } - if let lastAttempt = defaults?.object(forKey: SettingsKey.reviewLastAttempt) as? Date { - return now().timeIntervalSince(lastAttempt) >= 120 * 86_400 - } - return true - } - - private func accountForWorkspaceTime() { - let tick = uptime() - defer { lastTick = tick } - guard let lastTick, let activeSurfaceID, - let context = contexts[activeSurfaceID], context.isActive, context.location.isWorkspace - else { return } - workspaceSeconds += max(0, tick - lastTick) - } - - private func recordActiveDay() { - let date = now() - if defaults?.object(forKey: SettingsKey.reviewFirstUse) == nil { - defaults?.set(date, forKey: SettingsKey.reviewFirstUse) - } - var days = defaults?.stringArray(forKey: SettingsKey.reviewActiveDays) ?? [] - // Three distinct local dates prove eligibility; no growing usage log. - guard days.count < 3 else { return } - let parts = calendar.dateComponents([.era, .year, .month, .day], from: date) - let key = "\(parts.era ?? 0)-\(parts.year ?? 0)-\(parts.month ?? 0)-\(parts.day ?? 0)" - guard !days.contains(key) else { return } - days.append(key) - defaults?.set(days, forKey: SettingsKey.reviewActiveDays) + self.response = response + defaults?.set(response.rawValue, forKey: SettingsKey.reviewInvitationResponse) + } +} + +/// Eligibility is captured on entry, so age or a synced host cannot insert +/// an invitation into a page the user is already interacting with. +@MainActor +public struct AppReviewInvitationVisit { + private var eligibleOnEntry = false + + public init() {} + + public mutating func enter(controller: AppReviewController, hasHosts: Bool) { + eligibleOnEntry = controller.isEligible(hasHosts: hasHosts) + } + + public func isVisible(controller: AppReviewController, hasHosts: Bool, isFiltering: Bool) -> Bool { + if controller.isPreviewEnabled { + return controller.previewResponse == nil && !isFiltering + } + return eligibleOnEntry && controller.isEnabled && controller.response == nil && hasHosts && !isFiltering } } diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index 455364fc..234e1925 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -11,11 +11,9 @@ public nonisolated enum SettingsKey { public static let loggingSavingEnabled = "debug.logging.savingEnabled" public static let loggingLevel = "debug.logging.level" - // Device-local review invitation history; never included in Settings sync. + // Device-local review invitation state; never included in Settings sync. public static let reviewFirstUse = "app.review.firstUse" - public static let reviewActiveDays = "app.review.activeDays" - public static let reviewLastAttempt = "app.review.lastAttempt" - public static let reviewRequestedVersions = "app.review.requestedVersions" + public static let reviewInvitationResponse = "app.review.invitationResponse" public static let appLanguage = "app.language" public static let defaultICloudSyncEnabled = true diff --git a/Frameworks/TermindPresentation/AppReviewDeveloperSection.swift b/Frameworks/TermindPresentation/AppReviewDeveloperSection.swift new file mode 100644 index 00000000..6b2b9d5e --- /dev/null +++ b/Frameworks/TermindPresentation/AppReviewDeveloperSection.swift @@ -0,0 +1,24 @@ +import SwiftUI +import TermindApplication + +public struct AppReviewDeveloperSection: View { + private let controller: AppReviewController + + public init(controller: AppReviewController) { + self.controller = controller + } + + public var body: some View { + Section { + Toggle( + AppStrings.AppReview.showInvitation.swiftUIKey, + isOn: Binding(get: { controller.isPreviewEnabled }, set: controller.setPreviewEnabled) + ) + .accessibilityIdentifier("review.preview") + } header: { + Text(AppStrings.AppReview.previewTitle) + } footer: { + Text(AppStrings.AppReview.previewExplanation) + } + } +} diff --git a/Frameworks/TermindPresentation/AppReviewInvitationView.swift b/Frameworks/TermindPresentation/AppReviewInvitationView.swift new file mode 100644 index 00000000..8a99dab8 --- /dev/null +++ b/Frameworks/TermindPresentation/AppReviewInvitationView.swift @@ -0,0 +1,104 @@ +import SwiftUI +import TermindApplication + +/// A user-initiated store link, also used by the permanent Settings entry. +public struct AppReviewLink: View { + @Environment(\.openURL) private var openURL + private let controller: AppReviewController? + private let label: Label + + public init(controller: AppReviewController?, @ViewBuilder label: () -> Label) { + self.controller = controller + self.label = label() + } + + public var body: some View { + Button { + if let controller { + controller.openReview { url, completion in + openURL(url, completion: completion) + } + } else { + openURL(AppReviewController.reviewURL) + } + } label: { + label + } + } +} + +public struct AppReviewInvitationView: View { + private let controller: AppReviewController + + public init(controller: AppReviewController) { + self.controller = controller + } + + public var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 24) { + message + Spacer(minLength: 0) + actions + } + VStack(alignment: .leading, spacing: 12) { + message + actions + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("review.invitation") + } + + private var message: some View { + VStack(alignment: .leading, spacing: 4) { + Text(AppStrings.AppReview.title) + .font(.headline) + .accessibilityAddTraits(.isHeader) + Text(AppStrings.AppReview.message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .fixedSize(horizontal: false, vertical: true) + } + + private var actions: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 16) { + accept + decline + } + VStack(alignment: .leading, spacing: 8) { + accept + decline + } + } + .buttonStyle(.borderless) + .fixedSize(horizontal: false, vertical: true) + } + + private var accept: some View { + AppReviewLink(controller: controller) { + Text(AppStrings.AppReview.accept) + .padding(.vertical, 8) + .frame(minHeight: 44) + .contentShape(.rect) + } + .accessibilityHint(Text(AppStrings.Settings.rateOnTheAppStore)) + .accessibilityIdentifier("review.accept") + } + + private var decline: some View { + Button(action: controller.decline) { + Text(AppStrings.AppReview.decline) + .padding(.vertical, 8) + .frame(minHeight: 44) + .contentShape(.rect) + } + .foregroundStyle(.secondary) + .accessibilityIdentifier("review.decline") + } +} diff --git a/Frameworks/TermindPresentation/Strings/AppReview.swift b/Frameworks/TermindPresentation/Strings/AppReview.swift new file mode 100644 index 00000000..1e0e046a --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/AppReview.swift @@ -0,0 +1,15 @@ +import Foundation + +extension AppStrings { + public nonisolated enum AppReview { + public static var title: LocalizedStringResource { "Enjoying Termind?" } + public static var message: LocalizedStringResource { "Share your experience on the App Store." } + public static var accept: LocalizedStringResource { "Yes" } + public static var decline: LocalizedStringResource { "No, thanks" } + public static var previewTitle: LocalizedStringResource { "Review Invitation" } + public static var showInvitation: LocalizedStringResource { "Show Review Invitation" } + public static var previewExplanation: LocalizedStringResource { + "Show a test invitation on the hosts list. It does not open the App Store or change your review preference." + } + } +} diff --git a/TFI/App/MobileAppReviewPresenter.swift b/TFI/App/MobileAppReviewPresenter.swift deleted file mode 100644 index 8c958b4b..00000000 --- a/TFI/App/MobileAppReviewPresenter.swift +++ /dev/null @@ -1,66 +0,0 @@ -import StoreKit -import SwiftUI -import TermindApplication - -struct MobileAppReviewPresenter: ViewModifier { - static let reviewPages: Set = [ - "vault.hosts", "vault.keychain", "vault.snippets", "vault.knownHosts", - "settings", "settings.app", - ] - - @Environment(\.requestReview) private var requestReview - let app: MobileAppModel - @Bindable var scene: MobileSceneModel - let scenePhase: ScenePhase - - func body(content: Content) -> some View { - content.background { - if app.appReview.isEnabled { - MobileAppReviewWindowReader( - controller: app.appReview, - surfaceID: scene.id, - location: Self.location(in: scene), - sceneIsActive: scenePhase == .active, - isBlocked: scene.isConnectionPickerPresented - || scene.editingHost != nil - || scene.renamingWorkspace != nil - || scene.isWorkspaceUnavailable - || app.error != nil - || app.pro.isPaywallPresented - || app.pro.isProcessing, - request: { requestReview() } - ) - } - } - } - - static func location(in scene: MobileSceneModel) -> AppReviewController.Location { - if let workspace = scene.activeWorkspace { return .workspace(workspace.id) } - guard let page = scene.reviewPageID, reviewPages.contains(page) else { return .other } - switch scene.selectedDestination { - case .vault where page.hasPrefix("vault."): - return .reviewPage(page) - case .settings where page == "settings" || page.hasPrefix("settings."): - return .reviewPage(page) - default: - return .other - } - } -} - -private struct MobileReviewPageModifier: ViewModifier { - let id: String - let scene: MobileSceneModel? - - func body(content: Content) -> some View { - content - .onAppear { scene?.enterReviewPage(id) } - .onDisappear { scene?.leaveReviewPage(id) } - } -} - -extension View { - func mobileReviewPage(_ id: String, scene: MobileSceneModel?) -> some View { - modifier(MobileReviewPageModifier(id: id, scene: scene)) - } -} diff --git a/TFI/App/MobileFeatureRegistry.swift b/TFI/App/MobileFeatureRegistry.swift index fb5adf4a..0a877925 100644 --- a/TFI/App/MobileFeatureRegistry.swift +++ b/TFI/App/MobileFeatureRegistry.swift @@ -59,7 +59,7 @@ struct MobileFeatureRegistry { AnyView( MobileVaultView( app: app, - reviewScene: scene, + scene: scene, editingHost: Bindable(scene).projectedValue.editingHost, activateHost: scene.activateHost, open: scene.open, @@ -106,7 +106,7 @@ struct MobileFeatureRegistry { title: AppStrings.Navigation.settings, symbol: "gearshape" ) { app, scene in - AnyView(MobileSettingsView(app: app, reviewScene: scene)) + AnyView(MobileSettingsView(app: app)) } ) return registry diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 78dede64..ab216c73 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -118,7 +118,6 @@ struct MobileRootView: View { AppStrings.Workspace.unavailableWorkspaceExplanation ) : app.error ?? "") } - .modifier(MobileAppReviewPresenter(app: app, scene: scene, scenePhase: scenePhase)) .environment(\.mobileLayout, MobileLayout(sizeClass: sizeClass)) } @@ -146,7 +145,6 @@ struct MobileRootView: View { switch route { case .history: MobileConnectionHistoryView(app: app, open: scene.open) - .mobileReviewPage("vault.history", scene: scene) } } } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index fc3514b1..a1beacbe 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -92,7 +92,6 @@ final class MobileSceneModel { guard let id = workspaceSession.reopenClosedWorkspace(id) else { return } selectWorkspace(id) } - private(set) var reviewPageID: String? @ObservationIgnored private let app: MobileAppModel @ObservationIgnored private var pendingWorkspaceID: MobileWorkspace.ID? @@ -135,14 +134,6 @@ final class MobileSceneModel { workspaceDestination = .transfers } - func enterReviewPage(_ id: String) { - reviewPageID = id - } - - func leaveReviewPage(_ id: String) { - if reviewPageID == id { reviewPageID = nil } - } - func openConnectionPicker() { pendingBatchStartup = nil pendingWorkspaceID = nil diff --git a/TFI/Features/Ports/MobileForwardingView.swift b/TFI/Features/Ports/MobileForwardingView.swift index 1f93395f..1a547bee 100644 --- a/TFI/Features/Ports/MobileForwardingView.swift +++ b/TFI/Features/Ports/MobileForwardingView.swift @@ -7,16 +7,14 @@ import UIKit struct MobileForwardingView: View { let app: MobileAppModel - let reviewScene: MobileSceneModel? @State private var query: String @State private var editing: PortForwardingRule? @State private var showing: UUID? @State private var removing: PortForwardingRule? @State private var error: String? - init(app: MobileAppModel, initialQuery: String = "", reviewScene: MobileSceneModel? = nil) { + init(app: MobileAppModel, initialQuery: String = "") { self.app = app - self.reviewScene = reviewScene _query = State(initialValue: initialQuery) } @@ -48,7 +46,6 @@ struct MobileForwardingView: View { } .navigationDestination(item: $showing) { id in MobileForwardingDetailView(app: app, ruleID: id) - .mobileReviewPage("vault.portForwarding.detail", scene: reviewScene) } .sheet(item: $editing) { rule in MobileForwardingEditor(app: app, rule: rule, isNew: app.forwardingLibrary.portForwarding(rule.id) == nil) diff --git a/TFI/Features/Settings/MobileAppSettingsView.swift b/TFI/Features/Settings/MobileAppSettingsView.swift index 9115835d..ab8040d4 100644 --- a/TFI/Features/Settings/MobileAppSettingsView.swift +++ b/TFI/Features/Settings/MobileAppSettingsView.swift @@ -5,7 +5,6 @@ import TermindPresentation struct MobileAppSettingsView: View { let workspaceSettings: WorkspaceSettingsModel var appReview: AppReviewController? - var reviewScene: MobileSceneModel? var diagnosis = DiagnosisController() @AppStorage("mobile.appearance") private var appearance = "system" @AppStorage(SettingsKey.debugSectionEnabled) private var debugSectionEnabled = false @@ -49,16 +48,9 @@ struct MobileAppSettingsView: View { LabeledContent(AppStrings.Tailscale.title.swiftUIKey, value: MobileRuntimeVersions.tailscale) } Section(AppStrings.Settings.support.swiftUIKey) { - Link(destination: AppReviewController.reviewURL) { + AppReviewLink(controller: appReview) { Label(AppStrings.Settings.rateOnTheAppStore.swiftUIKey, systemImage: "star") } - .environment( - \.openURL, - OpenURLAction { url in - appReview?.recordManualReview() - return .systemAction(url) - } - ) Link(destination: URL(string: "https://termind.akinokaede.com/support/")!) { Label(AppStrings.Settings.support.swiftUIKey, systemImage: "questionmark.circle") } @@ -68,6 +60,5 @@ struct MobileAppSettingsView: View { } } .navigationTitle(AppStrings.Settings.app.swiftUIKey) - .mobileReviewPage("settings.app", scene: reviewScene) } } diff --git a/TFI/Features/Settings/MobileSettingsView.swift b/TFI/Features/Settings/MobileSettingsView.swift index 46ca9a5a..d00c2954 100644 --- a/TFI/Features/Settings/MobileSettingsView.swift +++ b/TFI/Features/Settings/MobileSettingsView.swift @@ -4,7 +4,6 @@ import TermindPresentation struct MobileSettingsView: View { let app: MobileAppModel - var reviewScene: MobileSceneModel? = nil @AppStorage(SettingsKey.debugSectionEnabled) private var debugSectionEnabled = false var body: some View { @@ -19,7 +18,6 @@ struct MobileSettingsView: View { MobileAppSettingsView( workspaceSettings: app.workspaceSettings, appReview: app.appReview, - reviewScene: reviewScene, diagnosis: app.diagnosis) } label: { Label(AppStrings.Settings.app.swiftUIKey, systemImage: "app.badge") @@ -62,6 +60,14 @@ struct MobileSettingsView: View { } if debugSectionEnabled { Section { + NavigationLink { + Form { + AppReviewDeveloperSection(controller: app.appReview) + } + .navigationTitle(AppStrings.Settings.developer.swiftUIKey) + } label: { + Label(AppStrings.Settings.developer.swiftUIKey, systemImage: "hammer") + } NavigationLink { MobileLoggingView(logging: app.logging) } label: { @@ -76,6 +82,5 @@ struct MobileSettingsView: View { } } .navigationTitle(AppStrings.Navigation.settings.swiftUIKey) - .mobileReviewPage("settings", scene: reviewScene) } } diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 65430b3c..f2613370 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -287,7 +287,7 @@ struct MobileVaultView: View { @Environment(\.locale) private var locale let app: MobileAppModel - let reviewScene: MobileSceneModel? + let scene: MobileSceneModel? let activateHost: (Host.ID) -> Void let open: (UUID, ConnectionHistoryEntry.Kind) -> Void let executeSnippet: (Snippet, [Host.ID]) -> Void @@ -298,10 +298,11 @@ struct MobileVaultView: View { @State private var deletingHost: Host? @State private var groupEditorRequest: GroupEditorRequest? @State private var deletingGroup: HostGroup? + @State private var reviewVisit = AppReviewInvitationVisit() init( app: MobileAppModel, - reviewScene: MobileSceneModel? = nil, + scene: MobileSceneModel? = nil, editingHost: Binding, activateHost: @escaping (Host.ID) -> Void, open: @escaping (UUID, ConnectionHistoryEntry.Kind) -> Void, @@ -309,7 +310,7 @@ struct MobileVaultView: View { openMetrics: (() -> Void)? = nil ) { self.app = app - self.reviewScene = reviewScene + self.scene = scene _editingHost = editingHost self.activateHost = activateHost self.open = open @@ -458,6 +459,11 @@ struct MobileVaultView: View { MobileCardPage { if query.isEmpty { vaultLinks + if reviewVisit.isVisible( + controller: app.appReview, hasHosts: !app.library.hosts.isEmpty, isFiltering: false + ) { + AppReviewInvitationView(controller: app.appReview) + } if app.library.hosts.isEmpty && app.library.groups.isEmpty { ContentUnavailableView( AppStrings.Vault.noHosts.swiftUIKey, systemImage: "server.rack", @@ -480,6 +486,9 @@ struct MobileVaultView: View { } } .searchable(text: $query.singleLineText) + .onAppear { + reviewVisit.enter(controller: app.appReview, hasHosts: !app.library.hosts.isEmpty) + } .navigationTitle(AppStrings.Navigation.vault.swiftUIKey) .toolbar { ToolbarItem(placement: .primaryAction) { @@ -505,7 +514,6 @@ struct MobileVaultView: View { group: request.groupID.flatMap(app.library.group), parentID: request.parentID) } - .mobileReviewPage("vault.hosts", scene: reviewScene) } @ViewBuilder @@ -565,27 +573,22 @@ struct MobileVaultView: View { MobileVaultLinkGroup { vaultLink(AppStrings.Navigation.keychain, symbol: "key") { MobileCredentialsView(app: app) - .mobileReviewPage("vault.keychain", scene: reviewScene) } MobileVaultLinkDivider() vaultLink(AppStrings.Navigation.portForwarding, symbol: "arrow.left.arrow.right") { - MobileForwardingView(app: app, reviewScene: reviewScene) - .mobileReviewPage("vault.portForwarding", scene: reviewScene) + MobileForwardingView(app: app) } MobileVaultLinkDivider() vaultLink(AppStrings.Navigation.snippets, symbol: "curlybraces") { MobileSnippetsView(app: app, execute: executeSnippet) - .mobileReviewPage("vault.snippets", scene: reviewScene) } MobileVaultLinkDivider() vaultLink(AppStrings.Navigation.knownHosts, symbol: "touchid") { MobileKnownHostsView(app: app) - .mobileReviewPage("vault.knownHosts", scene: reviewScene) } MobileVaultLinkDivider() vaultLink(AppStrings.Common.history, symbol: "clock.arrow.circlepath") { MobileConnectionHistoryView(app: app, open: open) - .mobileReviewPage("vault.history", scene: reviewScene) } } } @@ -633,23 +636,18 @@ struct MobileVaultView: View { switch destination { case .keychain(let useQuery): MobileCredentialsView(app: app, initialQuery: useQuery ? query : "") - .mobileReviewPage("vault.keychain", scene: reviewScene) case .portForwarding(let useQuery): - MobileForwardingView(app: app, initialQuery: useQuery ? query : "", reviewScene: reviewScene) - .mobileReviewPage("vault.portForwarding", scene: reviewScene) + MobileForwardingView(app: app, initialQuery: useQuery ? query : "") case .snippets(let useQuery): MobileSnippetsView( app: app, initialQuery: useQuery ? query : "", execute: executeSnippet ) - .mobileReviewPage("vault.snippets", scene: reviewScene) case .knownHosts(let useQuery): MobileKnownHostsView(app: app, initialQuery: useQuery ? query : "") - .mobileReviewPage("vault.knownHosts", scene: reviewScene) case .history(let useQuery): MobileConnectionHistoryView( app: app, initialQuery: useQuery ? query : "", open: open ) - .mobileReviewPage("vault.history", scene: reviewScene) case .metrics: EmptyView() } @@ -772,7 +770,7 @@ struct MobileVaultView: View { HStack(alignment: .top, spacing: 12) { HostConnectionIcon( host: host, - status: reviewScene?.workspaceActivatedByHostRow(for: host).flatMap { + status: scene?.workspaceActivatedByHostRow(for: host).flatMap { MobileWorkspaceConnectionIndicator.resolve( connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil )?.status diff --git a/TFI/Platform/MobileAppReviewWindowReader.swift b/TFI/Platform/MobileAppReviewWindowReader.swift deleted file mode 100644 index e8dc0f4c..00000000 --- a/TFI/Platform/MobileAppReviewWindowReader.swift +++ /dev/null @@ -1,261 +0,0 @@ -import SwiftUI -import TermindApplication -import UIKit -import UIKit.UIGestureRecognizerSubclass - -/// Observes one UIKit window without consuming input. SwiftUI owns navigation; -/// this adapter owns native focus, presentations, pointer input, and key presses. -struct MobileAppReviewWindowReader: UIViewRepresentable { - let controller: AppReviewController - let surfaceID: UUID - let location: AppReviewController.Location - let sceneIsActive: Bool - let isBlocked: Bool - let request: () -> Void - - func makeUIView(context: Context) -> MobileReviewActivityView { - MobileReviewActivityView(controller: controller, surfaceID: surfaceID) - } - - func updateUIView(_ view: MobileReviewActivityView, context: Context) { - view.location = location - view.sceneIsActive = sceneIsActive - view.isBlocked = isBlocked - view.request = request - view.refresh() - } - - static func dismantleUIView(_ view: MobileReviewActivityView, coordinator: ()) { - view.stop() - } -} - -final class MobileReviewActivityView: UIView, UIGestureRecognizerDelegate { - private let controller: AppReviewController - private let surfaceID: UUID - var location: AppReviewController.Location = .other - var sceneIsActive = false - var isBlocked = false - var request: () -> Void = {} - private weak var observedWindow: UIWindow? - private var observers: [any NSObjectProtocol] = [] - private var inputRecognizer: ReviewInteractionGestureRecognizer? - private var hoverRecognizer: UIHoverGestureRecognizer? - private var timer: Timer? - - init(controller: AppReviewController, surfaceID: UUID) { - self.controller = controller - self.surfaceID = surfaceID - super.init(frame: .zero) - isUserInteractionEnabled = false - } - - @available(*, unavailable) - required init?(coder: NSCoder) { nil } - - isolated deinit { stop() } - - override func didMoveToWindow() { - super.didMoveToWindow() - guard window !== observedWindow else { - refresh() - return - } - detach(removeSurface: false) - guard let window else { - controller.removeSurface(surfaceID) - return - } - observedWindow = window - if window.isKeyWindow, MobileReviewSurfaceFocus.activeSurfaceID == nil { - MobileReviewSurfaceFocus.activeSurfaceID = surfaceID - } - installInputObservers(on: window) - installLifecycleObservers(on: window) - refresh() - } - - func refresh() { - let available = - sceneIsActive - && observedWindow?.windowScene?.activationState == .foregroundActive - && observedWindow?.isKeyWindow == true - && observedWindow?.isHidden == false - if available, MobileReviewSurfaceFocus.activeSurfaceID == nil { - MobileReviewSurfaceFocus.activeSurfaceID = surfaceID - } - let active = available && MobileReviewSurfaceFocus.activeSurfaceID == surfaceID - controller.update( - surfaceID: surfaceID, - context: .init( - location: location, - isActive: active, - isBlocked: isBlocked || observedWindow?.rootViewController?.presentedViewController != nil - ) - ) - if active { - if timer == nil { - let timer = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in - MainActor.assumeIsolated { self?.refresh() } - } - timer.tolerance = 0.1 - RunLoop.main.add(timer, forMode: .common) - self.timer = timer - } - controller.requestIfReady(surfaceID: surfaceID, request: request) - } else { - timer?.invalidate() - timer = nil - } - } - - func stop() { - detach(removeSurface: true) - } - - private func installInputObservers(on window: UIWindow) { - let input = ReviewInteractionGestureRecognizer { [weak self] in self?.recordInteraction() } - input.delegate = self - input.cancelsTouchesInView = false - input.delaysTouchesBegan = false - input.delaysTouchesEnded = false - window.addGestureRecognizer(input) - inputRecognizer = input - - let hover = UIHoverGestureRecognizer(target: self, action: #selector(recordInteraction)) - hover.delegate = self - hover.cancelsTouchesInView = false - window.addGestureRecognizer(hover) - hoverRecognizer = hover - } - - private func installLifecycleObservers(on window: UIWindow) { - for name in [UIWindow.didBecomeKeyNotification, UIWindow.didResignKeyNotification] { - observers.append( - NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { - guard let self else { return } - if name == UIWindow.didBecomeKeyNotification { - MobileReviewSurfaceFocus.activeSurfaceID = self.surfaceID - } else { - MobileReviewSurfaceFocus.remove(self.surfaceID) - } - self.refresh() - } - } - ) - } - if let windowScene = window.windowScene { - for name in [UIScene.didActivateNotification, UIScene.willDeactivateNotification] { - observers.append( - NotificationCenter.default.addObserver(forName: name, object: windowScene, queue: .main) { - [weak self] _ in - MainActor.assumeIsolated { - guard let self else { return } - if name == UIScene.didActivateNotification { - MobileReviewSurfaceFocus.activeSurfaceID = self.surfaceID - } else { - MobileReviewSurfaceFocus.remove(self.surfaceID) - } - self.refresh() - } - } - ) - } - } - for name in [UIApplication.didBecomeActiveNotification, UIApplication.willResignActiveNotification] { - observers.append( - NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.refresh() } - } - ) - } - } - - private func detach(removeSurface: Bool) { - timer?.invalidate() - timer = nil - for observer in observers { NotificationCenter.default.removeObserver(observer) } - observers.removeAll() - if let inputRecognizer { observedWindow?.removeGestureRecognizer(inputRecognizer) } - if let hoverRecognizer { observedWindow?.removeGestureRecognizer(hoverRecognizer) } - inputRecognizer = nil - hoverRecognizer = nil - observedWindow = nil - if removeSurface { - MobileReviewSurfaceFocus.remove(surfaceID) - controller.removeSurface(surfaceID) - } - } - - @objc private func recordInteraction() { - MobileReviewSurfaceFocus.activeSurfaceID = surfaceID - controller.recordInteraction(surfaceID: surfaceID) - refresh() - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - true - } -} - -@MainActor -private enum MobileReviewSurfaceFocus { - static var activeSurfaceID: UUID? - - static func remove(_ surfaceID: UUID) { - if activeSurfaceID == surfaceID { activeSurfaceID = nil } - } -} - -private final class ReviewInteractionGestureRecognizer: UIGestureRecognizer { - private let interaction: () -> Void - - init(interaction: @escaping () -> Void) { - self.interaction = interaction - super.init(target: nil, action: nil) - } - - override func touchesBegan(_ touches: Set, with event: UIEvent) { - interaction() - } - - override func touchesMoved(_ touches: Set, with event: UIEvent) { - interaction() - } - - override func touchesEnded(_ touches: Set, with event: UIEvent) { - state = .failed - } - - override func touchesCancelled(_ touches: Set, with event: UIEvent) { - state = .failed - } - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent) { - interaction() - } - - override func pressesChanged(_ presses: Set, with event: UIPressesEvent) { - interaction() - } - - override func pressesEnded(_ presses: Set, with event: UIPressesEvent) { - state = .failed - } - - override func pressesCancelled(_ presses: Set, with event: UIPressesEvent) { - state = .failed - } - - override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { - false - } - - override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool { - false - } -} diff --git a/TFITests/MobileAppReviewTests.swift b/TFITests/MobileAppReviewTests.swift index d49224f6..72cd7239 100644 --- a/TFITests/MobileAppReviewTests.swift +++ b/TFITests/MobileAppReviewTests.swift @@ -1,88 +1,51 @@ +import Foundation +import SwiftUI import TermindApplication -import TermindDomain +import TermindPresentation import Testing import UIKit @testable import TermindIOS -@Suite -@MainActor +@Suite @MainActor struct MobileAppReviewTests { @Test - func presenterAllowsOnlyMobileEquivalentsOfDesktopReviewPages() throws { - let app = try MobileAppModel(inMemory: true) - let scene = MobileSceneModel(app: app, sessionID: "test") - - for page in ["vault.hosts", "vault.keychain", "vault.snippets", "vault.knownHosts"] { - scene.selectedDestination = .vault - scene.enterReviewPage(page) - #expect(MobileAppReviewPresenter.location(in: scene) == .reviewPage(page)) - } - for page in ["settings", "settings.app"] { - scene.selectedDestination = .settings - scene.enterReviewPage(page) - #expect(MobileAppReviewPresenter.location(in: scene) == .reviewPage(page)) - } - - scene.selectedDestination = .vault - scene.enterReviewPage("vault.metrics") - #expect(MobileAppReviewPresenter.location(in: scene) == .other) - scene.enterReviewPage("settings.app") - #expect(MobileAppReviewPresenter.location(in: scene) == .other) - scene.selectedDestination = .assistant - #expect(MobileAppReviewPresenter.location(in: scene) == .other) - } - - @Test - func workspaceDetailWinsOverAStaleReviewPage() throws { - let app = try MobileAppModel(inMemory: true) - let host = Host(name: "Review Test", host: "127.0.0.1") - app.library.upsert(host) - let workspaceID = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh)) - defer { app.testWorkspaceSession.closeAll() } - let scene = MobileSceneModel(app: app, sessionID: "test") - scene.enterReviewPage("vault.hosts") - scene.selectedDestination = .workspaces - scene.workspaceID = workspaceID - - #expect(MobileAppReviewPresenter.location(in: scene) == .workspace(workspaceID)) - } - - @Test - func pageLifecycleDoesNotLetAnOutgoingPageClearItsReplacement() throws { - let app = try MobileAppModel(inMemory: true) - let scene = MobileSceneModel(app: app, sessionID: "test") - scene.enterReviewPage("vault.hosts") - scene.enterReviewPage("vault.keychain") - scene.leaveReviewPage("vault.hosts") - #expect(scene.reviewPageID == "vault.keychain") - scene.leaveReviewPage("vault.keychain") - #expect(scene.reviewPageID == nil) - } - - @Test - func testModelsDisableReviewRequestsAndAcceptAnInjectedController() throws { + func testModelsDisableInvitationsAndShareAnInjectedController() throws { let disabled = try MobileAppModel(inMemory: true) #expect(!disabled.appReview.isEnabled) - - let injected = AppReviewController(defaults: nil, version: "1.0", isEnabled: true) + let injected = AppReviewController(isEnabled: true) let app = try MobileAppModel(inMemory: true, appReview: injected) #expect(app.appReview === injected) + app.appReview.setPreviewEnabled(true) + #expect(injected.isPreviewEnabled) } - @Test - func nativeInputObserversDoNotPreventApplicationGestures() { - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) - let view = MobileReviewActivityView(controller: AppReviewController(), surfaceID: UUID()) - window.addSubview(view) - let installed = (window.gestureRecognizers ?? []).filter { $0.delegate === view } - - #expect(installed.count == 2) - #expect(installed.allSatisfy { !$0.cancelsTouchesInView }) - let input = installed.first { !($0 is UIHoverGestureRecognizer) } - #expect(input?.canPrevent(UITapGestureRecognizer()) == false) - - view.removeFromSuperview() - #expect((window.gestureRecognizers ?? []).allSatisfy { $0.delegate !== view }) + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 430.0, 768.0]) + func invitationFitsPhoneAndTabletWidths(language: String, width: Double) throws { + let controller = AppReviewController() + let variants: [(ColorScheme, DynamicTypeSize, String)] = [ + (.light, .large, "standard"), (.dark, .accessibility3, "accessible"), + ] + var standardHeight = 0 + for (scheme, typeSize, name) in variants { + let content = AppReviewInvitationView(controller: controller) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, scheme) + .environment(\.dynamicTypeSize, typeSize) + .frame(width: width) + .background(scheme == .light ? Color.white : Color.black) + let renderer = ImageRenderer(content: content) + let image = try #require(renderer.cgImage) + if name == "standard" { + standardHeight = image.height + } else { + #expect(image.height > standardHeight) + } + #expect(controller.response == nil) + if ProcessInfo.processInfo.environment["TERMIND_REVIEW_PREVIEW"] == "1" { + let png = try #require(UIImage(cgImage: image).pngData()) + Attachment.record(png, named: "termind-review-ios-\(language)-\(Int(width))-\(name).png") + } + } } } diff --git a/TFITests/MobileForwardingRenderingTests.swift b/TFITests/MobileForwardingRenderingTests.swift index ab009ead..af343e1f 100644 --- a/TFITests/MobileForwardingRenderingTests.swift +++ b/TFITests/MobileForwardingRenderingTests.swift @@ -32,7 +32,7 @@ struct MobileForwardingRenderingTests { features.register( MobileFeatureRegistration(id: .vault, title: "Vault", symbol: "server.rack") { app, scene in AnyView( - MobileForwardingView(app: app, reviewScene: scene) + MobileForwardingView(app: app) .navigationDestination( isPresented: Binding(get: { route.details }, set: { route.details = $0 }) ) { diff --git a/TFM/App/AppReviewPresenter.swift b/TFM/App/AppReviewPresenter.swift deleted file mode 100644 index 22a75716..00000000 --- a/TFM/App/AppReviewPresenter.swift +++ /dev/null @@ -1,45 +0,0 @@ -import StoreKit -import SwiftUI -import TermindApplication - -struct AppReviewPresenter: ViewModifier { - @Environment(AppModel.self) private var app - @Environment(\.requestReview) private var requestReview - let window: WindowModel - - func body(content: Content) -> some View { - content.background { - if app.appReview.isEnabled { - AppReviewWindowReader( - controller: app.appReview, location: Self.location(in: window), - isBlocked: app.onboarding.presentation != nil - || window.presentations.route != nil - || window.assistantOperationError != nil - || app.externalAuthentication.current != nil - || app.proAccess.isPaywallPresented || app.proAccess.isProcessing, - request: { requestReview() } - ) - } - } - } - - static func location(in window: WindowModel) -> AppReviewController.Location { - switch window.selectedDestination { - case .workspace(let id): - return .workspace(id) - case .vault: - guard window.vaultWorkRoute == nil, let page = window.vaultDestination.page, - page != .welcome - else { return .other } - return .reviewPage("vault.\(page.rawValue)") - case .settings: - switch window.settingsRoute { - case .index: return .reviewPage("settings") - case .category(.app): return .reviewPage("settings.app") - default: return .other - } - case .metrics, .assistant: - return .other - } - } -} diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 08de2fea..bae2877a 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -227,7 +227,6 @@ struct RootView: View { // layout drops without competing for the same text payload. .environment(window) .modifier(InitialNotificationAuthorization(model: app.notificationSettings, service: app.notifications)) - .modifier(AppReviewPresenter(window: window)) .proPaywallPresenter(app: app, window: window) .focusedSceneValue(\.window, window) .onChange(of: window.selectedDestination) { diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index 65838e24..4dd0da1a 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -65,6 +65,7 @@ struct HostsPageView: ManagedCardPage { @Environment(VaultWorkEditingSession.self) private var editingSession let state: HostsPageState + @State private var reviewVisit = AppReviewInvitationVisit() init(state: HostsPageState = HostsPageState()) { self.state = state @@ -99,6 +100,19 @@ struct HostsPageView: ManagedCardPage { // MARK: - Page + var hasManagedTopContent: Bool { + reviewVisit.isVisible( + controller: app.appReview, hasHosts: !app.hostLibrary.hosts.isEmpty, isFiltering: isFiltering + ) + } + + @ViewBuilder + func managedTopContent() -> some View { + if hasManagedTopContent { + AppReviewInvitationView(controller: app.appReview) + } + } + var managedSections: [ManagedCardSection] { sections.map { section in ManagedCardSection( @@ -349,6 +363,9 @@ struct HostsPageView: ManagedCardPage { HostsPagePresentation( state: state, liveTags: app.hostLibrary.hostTags, + onAppear: { + reviewVisit.enter(controller: app.appReview, hasHosts: !app.hostLibrary.hosts.isEmpty) + }, actions: HostsPagePresentation.Actions( deleteGroup: { delete($0, keepHosts: $1) }, deleteHosts: delete, @@ -395,11 +412,13 @@ struct HostsPagePresentation: ManagedCardPresentationModifier { let state: HostsPageState /// Tags at least one host still carries — see `HostsPageState.pruneSelectedTags`. let liveTags: [String] + let onAppear: () -> Void let actions: Actions func body(content: Content) -> some View { @Bindable var state = state content + .onAppear(perform: onAppear) .onChange(of: liveTags, initial: true) { _, tags in state.pruneSelectedTags(against: tags) } diff --git a/TFM/Features/Settings/DeveloperSettingsPage.swift b/TFM/Features/Settings/DeveloperSettingsPage.swift index c7202ac6..54e698e2 100644 --- a/TFM/Features/Settings/DeveloperSettingsPage.swift +++ b/TFM/Features/Settings/DeveloperSettingsPage.swift @@ -6,6 +6,7 @@ struct DeveloperSettingsPage: View { var body: some View { Form { + AppReviewDeveloperSection(controller: app.appReview) Section { ForEach(OnboardingStep.allCases) { step in onboardingRow(step) diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 7a6f1e5e..152964ad 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -508,17 +508,10 @@ private struct AppInfoSettings: View { } Section(AppStrings.Settings.support.swiftUIKey) { - Link(destination: AppReviewController.reviewURL) { + AppReviewLink(controller: app.appReview) { Label(AppStrings.Settings.rateOnTheAppStore.swiftUIKey, systemImage: "star") .frame(maxWidth: .infinity, alignment: .leading) } - .environment( - \.openURL, - OpenURLAction { url in - app.appReview.recordManualReview() - return .systemAction(url) - } - ) Link(destination: URL(string: "https://termind.akinokaede.com/support/")!) { Label(AppStrings.Settings.support.swiftUIKey, systemImage: "questionmark.circle") .frame(maxWidth: .infinity, alignment: .leading) diff --git a/TFM/Platform/AppReviewWindowReader.swift b/TFM/Platform/AppReviewWindowReader.swift deleted file mode 100644 index f76eb827..00000000 --- a/TFM/Platform/AppReviewWindowReader.swift +++ /dev/null @@ -1,144 +0,0 @@ -import AppKit -import SwiftUI -import TermindApplication - -/// Native focus and input belong to the window, including controls hosted outside -/// SwiftUI. A local event monitor observes input without consuming or storing it. -struct AppReviewWindowReader: NSViewRepresentable { - let controller: AppReviewController - let location: AppReviewController.Location - let isBlocked: Bool - let request: () -> Void - - func makeNSView(context: Context) -> ReviewActivityView { - ReviewActivityView(controller: controller) - } - - func updateNSView(_ view: ReviewActivityView, context: Context) { - view.location = location - view.isBlocked = isBlocked - view.request = request - view.refresh() - } - - static func dismantleNSView(_ view: ReviewActivityView, coordinator: ()) { - view.stop() - } -} - -final class ReviewActivityView: NSView { - private let controller: AppReviewController - private let windowID = UUID() - var location: AppReviewController.Location = .other - var isBlocked = false - var request: () -> Void = {} - private var observers: [any NSObjectProtocol] = [] - private var sleepObservers: [any NSObjectProtocol] = [] - private var monitor: Any? - private var timer: Timer? - private var isSleeping = false - - init(controller: AppReviewController) { - self.controller = controller - super.init(frame: .zero) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { nil } - - isolated deinit { stop() } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - stop() - guard window != nil else { return } - observers.append( - NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, object: window, queue: .main - ) { [weak self] _ in MainActor.assumeIsolated { self?.stop() } } - ) - for name in [ - NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification, - NSWindow.didMiniaturizeNotification, NSWindow.didDeminiaturizeNotification, - NSWindow.willBeginSheetNotification, NSWindow.didEndSheetNotification, - NSApplication.didBecomeActiveNotification, NSApplication.didResignActiveNotification, - ] { - observers.append( - NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { - [weak self] _ in - MainActor.assumeIsolated { - if let self, name == NSWindow.willBeginSheetNotification { - self.controller.recordInteraction(surfaceID: self.windowID) - } - self?.refresh() - } - } - ) - } - for name in [NSWorkspace.willSleepNotification, NSWorkspace.didWakeNotification] { - sleepObservers.append( - NSWorkspace.shared.notificationCenter.addObserver(forName: name, object: nil, queue: .main) { - [weak self] _ in - MainActor.assumeIsolated { - self?.isSleeping = name == NSWorkspace.willSleepNotification - self?.refresh() - } - } - ) - } - monitor = NSEvent.addLocalMonitorForEvents( - matching: [ - .keyDown, .flagsChanged, .leftMouseDown, .rightMouseDown, .otherMouseDown, - .leftMouseDragged, .rightMouseDragged, .otherMouseDragged, .scrollWheel, .mouseMoved, - ] - ) { [weak self] event in - MainActor.assumeIsolated { - if let self, self.window?.isKeyWindow == true { - self.controller.recordInteraction(surfaceID: self.windowID) - } - } - return event - } - refresh() - } - - func refresh() { - let active = - window.map { - NSApp.isActive && $0.isKeyWindow && $0.isVisible && !$0.isMiniaturized && !isSleeping - } ?? false - controller.update( - surfaceID: windowID, - context: .init( - location: location, isActive: active, - isBlocked: isBlocked || window?.attachedSheet != nil || NSApp.modalWindow != nil - ) - ) - if active { - if timer == nil { - let timer = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in - MainActor.assumeIsolated { self?.refresh() } - } - timer.tolerance = 0.1 - RunLoop.main.add(timer, forMode: .common) - self.timer = timer - } - controller.requestIfReady(surfaceID: windowID, request: request) - } else { - timer?.invalidate() - timer = nil - } - } - - func stop() { - timer?.invalidate() - timer = nil - for observer in observers { NotificationCenter.default.removeObserver(observer) } - observers.removeAll() - for observer in sleepObservers { NSWorkspace.shared.notificationCenter.removeObserver(observer) } - sleepObservers.removeAll() - if let monitor { NSEvent.removeMonitor(monitor) } - monitor = nil - controller.removeSurface(windowID) - } -} diff --git a/TFM/UI/Managed/CardGrid.swift b/TFM/UI/Managed/CardGrid.swift index 1d0c00ee..14a19839 100644 --- a/TFM/UI/Managed/CardGrid.swift +++ b/TFM/UI/Managed/CardGrid.swift @@ -517,7 +517,7 @@ private struct CardFrameModifier: ViewModifier { } extension View { - fileprivate func cardInteractiveFrame() -> some View { + func cardInteractiveFrame() -> some View { modifier(CardInteractiveFrameModifier()) } } diff --git a/TFM/UI/Managed/ManagedCardPage.swift b/TFM/UI/Managed/ManagedCardPage.swift index e05ed366..2e759c8e 100644 --- a/TFM/UI/Managed/ManagedCardPage.swift +++ b/TFM/UI/Managed/ManagedCardPage.swift @@ -111,6 +111,7 @@ protocol ManagedCardPage: View { associatedtype GhostContent: View associatedtype EmptyContent: View associatedtype NoResultsContent: View + associatedtype TopContent: View = EmptyView associatedtype Presentation: ManagedCardPresentationModifier var managedMinimumCardWidth: CGFloat { get } @@ -119,6 +120,7 @@ protocol ManagedCardPage: View { var managedSelection: ManagedCardSelection { get } var managedCapabilities: ManagedCardCapabilities { get } var isManagedSourceEmpty: Bool { get } + var hasManagedTopContent: Bool { get } var managedPresentation: Presentation { get } @ViewBuilder func managedCard( @@ -143,6 +145,7 @@ protocol ManagedCardPage: View { func managedBlankAreaPrimaryAction() @ViewBuilder func managedEmptyState() -> EmptyContent @ViewBuilder func managedNoResultsState() -> NoResultsContent + @ViewBuilder func managedTopContent() -> TopContent } extension ManagedCardPage { @@ -150,6 +153,8 @@ extension ManagedCardPage { var managedMaximumCardWidth: CGFloat { Metrics.Card.max } func managedBlankAreaMenu(for section: SectionID?) -> [NativeMenuEntry] { [] } func managedBlankAreaPrimaryAction() {} + func managedTopContent() -> EmptyView { EmptyView() } + var hasManagedTopContent: Bool { false } var body: some View { ManagedCardPageRenderer( @@ -159,11 +164,13 @@ extension ManagedCardPage { selection: managedSelection, capabilities: managedCapabilities, isSourceEmpty: isManagedSourceEmpty, + hasTopContent: hasManagedTopContent, card: managedCard, header: managedHeader, ghost: managedGhost, blankAreaMenu: managedBlankAreaMenu, blankAreaPrimaryAction: managedBlankAreaPrimaryAction, + topContent: managedTopContent, emptyState: managedEmptyState, noResultsState: managedNoResultsState ) @@ -231,11 +238,13 @@ struct ManagedCardPageRenderer: View { let selection: ManagedCardSelection let capabilities: ManagedCardCapabilities let isSourceEmpty: Bool + let hasTopContent: Bool let card: (Page.Item, ManagedCardItemContext) -> Page.CardContent let header: (ManagedCardSection, Bool) -> Page.HeaderContent let ghost: (Page.Item) -> Page.GhostContent let blankAreaMenu: (Page.SectionID?) -> [NativeMenuEntry] let blankAreaPrimaryAction: () -> Void + let topContent: () -> Page.TopContent let emptyState: () -> Page.EmptyContent let noResultsState: () -> Page.NoResultsContent @@ -244,13 +253,14 @@ struct ManagedCardPageRenderer: View { @State private var ghostLocation: ManagedCardDropLocation? @State private var itemFrames: [Page.Item.ID: CGRect] = [:] @State private var sectionFrames: [Page.SectionID: CGRect] = [:] + @State private var topContentFrame: CGRect = .zero @ViewBuilder var body: some View { Group { - if isSourceEmpty { + if isSourceEmpty && !hasTopContent { emptyState() - } else if !hasVisibleContent { + } else if !hasVisibleContent && !hasTopContent { noResultsState() } else { collection @@ -278,8 +288,23 @@ struct ManagedCardPageRenderer: View { blankAreaMenu: { point in blankAreaMenu(section(at: point)) }, blankAreaPrimaryAction: blankAreaPrimaryAction ) { - ForEach(sections) { section in - managedSection(section) + if hasTopContent { + topContent() + .cardInteractiveFrame() + .onGeometryChange(for: CGRect.self) { + $0.frame(in: .named(managedCardDragSpace)) + } action: { + topContentFrame = $0 + } + .onDisappear { topContentFrame = .zero } + } + if isSourceEmpty { + emptyState() + .frame(maxWidth: .infinity, minHeight: 280) + } else { + ForEach(sections) { section in + managedSection(section) + } } } .contentShape(.rect) @@ -464,7 +489,7 @@ struct ManagedCardPageRenderer: View { } private func dropLocation(at point: CGPoint) -> ManagedCardDropLocation? { - guard let reordering = activeReordering, let dragSession else { return nil } + guard !topContentFrame.contains(point), let reordering = activeReordering, let dragSession else { return nil } let moving = Set(dragSession.ids) let candidates = sections.compactMap { section -> ManagedCardDropSection? in diff --git a/TFMTests/AppReviewControllerTests.swift b/TFMTests/AppReviewControllerTests.swift index 3ca896be..d7a6babc 100644 --- a/TFMTests/AppReviewControllerTests.swift +++ b/TFMTests/AppReviewControllerTests.swift @@ -1,32 +1,60 @@ -import Foundation +import AppKit +import SwiftUI import TermindApplication +import TermindPresentation import Testing @testable import Termind -@Suite -@MainActor -struct AppReviewPresenterTests { - @Test - func onlyVaultListsAndAppSettingsAreReviewDestinations() { - let window = WindowModel(vaultDestination: .hosts) - #expect(AppReviewPresenter.location(in: window) == .reviewPage("vault.hosts")) - window.vaultWorkRoute = .hostEditor(nil) - #expect(AppReviewPresenter.location(in: window) == .other) - window.vaultWorkRoute = nil - window.vaultDestination = .welcome - #expect(AppReviewPresenter.location(in: window) == .other) - window.selectedDestination = .settings - #expect(AppReviewPresenter.location(in: window) == .reviewPage("settings")) - window.settingsRoute = .category(.app) - #expect(AppReviewPresenter.location(in: window) == .reviewPage("settings.app")) - window.settingsRoute = .category(.terminal) - #expect(AppReviewPresenter.location(in: window) == .other) - window.settingsRoute = .modelProvider(UUID()) - #expect(AppReviewPresenter.location(in: window) == .other) - for destination in [WindowDestination.assistant, .metrics] { - window.selectedDestination = destination - #expect(AppReviewPresenter.location(in: window) == .other) +@Suite @MainActor +struct AppReviewInvitationTests { + @Test(arguments: [false, true]) + func storeLinkRecordsOnlyAcceptedOpens(accepted: Bool) async throws { + let controller = AppReviewController(isEnabled: true) + var openedURLs: [URL] = [] + let openURL = OpenURLAction { url in + openedURLs.append(url) + return accepted ? .handled : .discarded + } + controller.openReview { url, completion in + openURL(url, completion: completion) + } + try await Task.sleep(for: .milliseconds(50)) + #expect(openedURLs == [AppReviewController.reviewURL]) + #expect(controller.response == (accepted ? .openedReview : nil)) + } + + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 800.0]) + func invitationRendersWithoutActivatingAWindowOrChangingPreference(language: String, width: Double) throws { + let controller = AppReviewController() + let hosting = NSHostingView( + rootView: AppReviewInvitationView(controller: controller) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, .light) + .frame(width: width) + .background(.background)) + hosting.frame = CGRect(origin: .zero, size: hosting.fittingSize) + hosting.layoutSubtreeIfNeeded() + #expect(hosting.fittingSize.width <= width) + #expect(controller.response == nil) + #expect(!controller.isPreviewEnabled) + if ProcessInfo.processInfo.environment["TERMIND_REVIEW_PREVIEW"] == "1" { + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + Attachment.record(png, named: "termind-review-macos-\(language)-\(Int(width)).png") } } + + @Test + func invitationAreaIsNotBlankCanvas() { + let invitation = CGRect(x: 24, y: 24, width: 600, height: 100) + #expect( + !CardMarqueeSelectionResolver.isBlank( + CGPoint(x: 500, y: 75), cardFrames: [], interactiveFrames: [invitation])) + #expect( + CardMarqueeSelectionResolver.isBlank( + CGPoint(x: 500, y: 140), cardFrames: [], interactiveFrames: [invitation])) + } + }