feat(review): replace automatic prompts with inline invitations
This commit is contained in:
1 parent
a5b269ee20
commit
a03397ad22
27 files changed
+799
-1092
No files matched your search
@@ -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 } }
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -517,7 +517,7 @@ private struct CardFrameModifier: ViewModifier {
|
||||
}
|
||||
|
||||
extension View {
|
||||
fileprivate func cardInteractiveFrame() -> some View {
|
||||
func cardInteractiveFrame() -> some View {
|
||||
modifier(CardInteractiveFrameModifier())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user