feat(review): replace automatic prompts with inline invitations

This commit is contained in:
Kaede Akino committed 2026-10-14 18:17:08 +08:00
1 parent a5b269ee20
commit a03397ad22
27 files changed
+799 -1092

No files matched your search

+127 -259
View File
@@ -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 } }
}
}
+287
View File
@@ -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" : {
@@ -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
}
}
@@ -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
@@ -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)
}
}
}
@@ -0,0 +1,104 @@
import SwiftUI
import TermindApplication
/// A user-initiated store link, also used by the permanent Settings entry.
public struct AppReviewLink<Label: View>: 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")
}
}
@@ -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."
}
}
}
-66
View File
@@ -1,66 +0,0 @@
import StoreKit
import SwiftUI
import TermindApplication
struct MobileAppReviewPresenter: ViewModifier {
static let reviewPages: Set<String> = [
"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))
}
}
+2 -2
View File
@@ -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
-2
View File
@@ -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)
}
}
}
-9
View File
@@ -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
@@ -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)
@@ -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)
}
}
@@ -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)
}
}
+15 -17
View File
@@ -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<Host?>,
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
@@ -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<UITouch>, with event: UIEvent) {
interaction()
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
interaction()
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
state = .failed
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
state = .failed
}
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent) {
interaction()
}
override func pressesChanged(_ presses: Set<UIPress>, with event: UIPressesEvent) {
interaction()
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent) {
state = .failed
}
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent) {
state = .failed
}
override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
false
}
override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool {
false
}
}
+35 -72
View File
@@ -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")
}
}
}
}
@@ -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 })
) {
-45
View File
@@ -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
}
}
}
-1
View File
@@ -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) {
+19
View File
@@ -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<HostLibrarySection.ID, Host>] {
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)
}
@@ -6,6 +6,7 @@ struct DeveloperSettingsPage: View {
var body: some View {
Form {
AppReviewDeveloperSection(controller: app.appReview)
Section {
ForEach(OnboardingStep.allCases) { step in
onboardingRow(step)
+1 -8
View File
@@ -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)
-144
View File
@@ -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)
}
}
+1 -1
View File
@@ -517,7 +517,7 @@ private struct CardFrameModifier: ViewModifier {
}
extension View {
fileprivate func cardInteractiveFrame() -> some View {
func cardInteractiveFrame() -> some View {
modifier(CardInteractiveFrameModifier())
}
}
+30 -5
View File
@@ -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<Item.ID> { get }
var managedCapabilities: ManagedCardCapabilities<SectionID, Item.ID> { 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<Self>(
@@ -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<Page: ManagedCardPage>: View {
let selection: ManagedCardSelection<Page.Item.ID>
let capabilities: ManagedCardCapabilities<Page.SectionID, Page.Item.ID>
let isSourceEmpty: Bool
let hasTopContent: Bool
let card: (Page.Item, ManagedCardItemContext<Page.Item>) -> Page.CardContent
let header: (ManagedCardSection<Page.SectionID, Page.Item>, 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<Page: ManagedCardPage>: View {
@State private var ghostLocation: ManagedCardDropLocation<Page.SectionID>?
@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<Page: ManagedCardPage>: 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<Page: ManagedCardPage>: View {
}
private func dropLocation(at point: CGPoint) -> ManagedCardDropLocation<Page.SectionID>? {
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<Page.SectionID, Page.Item.ID>? in
+52 -24
View File
@@ -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]))
}
}