Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apple/TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
SyncCoordinator.shared.start()
LinkedFolderWatcher.shared.start()
TeamLibrarySyncCoordinator.shared.start()
SupportAskCoordinator.shared.start()

Task {
LicenseManager.shared.startPeriodicValidation()
Expand Down
3 changes: 3 additions & 0 deletions apple/TablePro/Core/Services/Licensing/SupportLinks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal enum SupportReferrer: Equatable {
case aboutPanel
case licenseSettings
case activationSheet
case ask
case featureGate(ProFeature)

internal var parameterValue: String {
Expand All @@ -28,6 +29,8 @@ internal enum SupportReferrer: Equatable {
return "app-settings"
case .activationSheet:
return "app-activation"
case .ask:
return "app-ask"
case .featureGate(let feature):
return "app-gate-" + Self.slug(for: feature)
}
Expand Down
169 changes: 169 additions & 0 deletions apple/TablePro/Core/Services/Support/SupportAskCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//
// SupportAskCoordinator.swift
// TablePro
//

import AppKit
import Combine
import os

/// Counts days of use and puts the license ask on the window in use when `SupportAskSchedule`
/// says it is due and the moment is quiet.
///
/// It looks again whenever a window becomes key, the app becomes active, or the launch quiet
/// period ends, rather than polling: one of those happens soon after any moment the ask could use.
@MainActor
internal final class SupportAskCoordinator {
struct Environment {
var hasLicense: () -> Bool
var isQueryRunning: () -> Bool
var keyWindow: () -> NSWindow?
var isModalSessionRunning: () -> Bool
var openPricing: (URL) -> Void
var now: () -> Date
}

enum Keys {
static let lastAskedAt = "com.TablePro.support.lastAskedAt"
static let lastAnswer = "com.TablePro.support.lastAnswer"
}

private static let logger = Logger(subsystem: "com.TablePro", category: "SupportAsk")

private let counter: UsageDayCounter
private let defaults: UserDefaults
private let presenter: any SupportAskPresenting
private let environment: Environment
private let calendar: Calendar
private let clock: any Clock<Duration>

private var launchedAt: Date?
private var subscriptions: Set<AnyCancellable> = []
private var quietPeriodTask: Task<Void, Never>?

init(
counter: UsageDayCounter,
defaults: UserDefaults,
presenter: any SupportAskPresenting,
environment: Environment,
calendar: Calendar = .autoupdatingCurrent,
clock: any Clock<Duration> = ContinuousClock()
) {
self.counter = counter
self.defaults = defaults
self.presenter = presenter
self.environment = environment
self.calendar = calendar
self.clock = clock
}

func start() {
guard launchedAt == nil else { return }
launchedAt = environment.now()
counter.recordUse(at: environment.now())

let center = NotificationCenter.default
center.publisher(for: NSApplication.didBecomeActiveNotification)
.sink { [weak self] _ in self?.appBecameActive() }
.store(in: &subscriptions)
// Posted when the date rolls over while the app runs, from whichever thread noticed.
center.publisher(for: .NSCalendarDayChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.dayChanged() }
.store(in: &subscriptions)
center.publisher(for: NSWindow.didBecomeKeyNotification)
.sink { [weak self] _ in self?.askIfDue() }
.store(in: &subscriptions)

quietPeriodTask = Task { [weak self, clock] in
do {
try await clock.sleep(for: .seconds(SupportAskSchedule.launchQuietPeriod))
} catch {
return
}
self?.askIfDue()
}
}

var schedule: SupportAskSchedule {
SupportAskSchedule(
usageDays: counter.days,
hasLicense: environment.hasLicense(),
lastAskedAt: defaults.object(forKey: Keys.lastAskedAt) as? Date,
lastAnswer: defaults.string(forKey: Keys.lastAnswer).flatMap(SupportAskAnswer.init(rawValue:))
)
}

/// Presents the ask when everything allows it. Each check is cheap, and the first that fails
/// ends the look.
func askIfDue() {
guard let launchedAt, !presenter.isPresenting else { return }
let now = environment.now()
guard schedule.isDue(at: now, calendar: calendar) else { return }
guard SupportAskSchedule.isQuietMoment(
launchedAt: launchedAt,
now: now,
isQueryRunning: environment.isQueryRunning()
) else { return }
guard !environment.isModalSessionRunning(),
let window = environment.keyWindow(),
Self.canHostAsk(window) else { return }

let presented = presenter.present(usageDays: counter.days, on: window) { [weak self] answer in
self?.record(answer)
}
guard presented else { return }
// Recorded on showing rather than on answering, so quitting with the sheet open still
// counts as an ask and the next launch does not repeat it.
defaults.set(now, forKey: Keys.lastAskedAt)
defaults.removeObject(forKey: Keys.lastAnswer)
Self.logger.info("Showed the license ask after \(self.counter.days, privacy: .public) days of use")
}

/// Only a window someone works in carries the ask: a connection, a data file or the welcome
/// window. Settings, the Support window and other utility windows never do.
static func canHostAsk(_ window: NSWindow) -> Bool {
let identifier = window.identifier?.rawValue
let isWorkWindow = ConnectionWindowIdentity.isPrimaryWindow(identifier)
|| ConnectionWindowIdentity.isDataFileWindow(identifier)
|| ConnectionWindowIdentity.isWelcomeWindow(identifier)
return isWorkWindow
&& window.isVisible
&& !window.isMiniaturized
&& window.attachedSheet == nil
&& window.sheetParent == nil
}

private func record(_ answer: SupportAskAnswer) {
defaults.set(answer.rawValue, forKey: Keys.lastAnswer)
if answer == .buy {
environment.openPricing(SupportLinks.pricing(.ask))
}
}

private func appBecameActive() {
counter.recordUse(at: environment.now())
askIfDue()
}

private func dayChanged() {
guard NSApp.isActive else { return }
counter.recordUse(at: environment.now())
}
}

extension SupportAskCoordinator {
static let shared = SupportAskCoordinator(
counter: .shared,
defaults: AppStorageEnvironment.shared.defaults,
presenter: SupportAskSheetPresenter(),
environment: Environment(
hasLicense: { LicenseManager.shared.supportAudience == .supporter },
isQueryRunning: { MainContentCoordinator.isAnyQueryRunning },
keyWindow: { NSApp.keyWindow },
isModalSessionRunning: { NSApp.modalWindow != nil },
openPricing: { NSWorkspace.shared.open($0) },
now: { Date() }
)
)
}
45 changes: 45 additions & 0 deletions apple/TablePro/Core/Services/Support/SupportAskSchedule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// SupportAskSchedule.swift
// TablePro
//

import Foundation

/// How someone answered the license ask. The raw values are stored, so they never change.
internal enum SupportAskAnswer: String, Equatable, Sendable {
case buy
case personal
case notNow
}

/// When the app may ask someone without a license to buy one.
///
/// The 30th day of use opens it. Each ask closes it again for 90 days, or for a year once the
/// person says they use TablePro personally, and a license closes it for good. Kept pure, so every
/// rule is tested without a window, a clock or a license.
internal struct SupportAskSchedule: Equatable, Sendable {
static let firstAskDay = 30
static let daysBetweenAsks = 90
static let daysAfterPersonalUse = 365
/// Someone who just launched the app came to do something; the ask waits until they have.
static let launchQuietPeriod: TimeInterval = 5 * 60

var usageDays: Int
var hasLicense: Bool
var lastAskedAt: Date?
var lastAnswer: SupportAskAnswer?

func isDue(at now: Date, calendar: Calendar) -> Bool {
guard !hasLicense, usageDays >= Self.firstAskDay else { return false }
guard let lastAskedAt else { return true }
let waitDays = lastAnswer == .personal ? Self.daysAfterPersonalUse : Self.daysBetweenAsks
guard let nextAsk = calendar.date(byAdding: .day, value: waitDays, to: lastAskedAt) else { return false }
return now >= nextAsk
}

/// Whether this is a moment the ask may interrupt: past the launch quiet period, and with no
/// query running, so a sheet never lands on a result someone is waiting for.
static func isQuietMoment(launchedAt: Date, now: Date, isQueryRunning: Bool) -> Bool {
!isQueryRunning && now.timeIntervalSince(launchedAt) >= launchQuietPeriod
}
}
46 changes: 46 additions & 0 deletions apple/TablePro/Core/Services/Support/UsageDayCounter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// UsageDayCounter.swift
// TablePro
//

import Foundation

/// Counts the distinct local days TablePro was in use, so the license ask can tell someone trying
/// the app from someone who has made it part of their week.
///
/// The count stays in this Mac's defaults and is never sent anywhere. A day is stored as its
/// calendar date rather than a timestamp, so recording it twice is harmless however often the app
/// becomes active.
@MainActor
internal final class UsageDayCounter {
static let shared = UsageDayCounter(defaults: AppStorageEnvironment.shared.defaults)

enum Keys {
static let days = "com.TablePro.support.usageDays"
static let lastDay = "com.TablePro.support.lastUsageDay"
}

private let defaults: UserDefaults
private let calendar: Calendar

init(defaults: UserDefaults, calendar: Calendar = .autoupdatingCurrent) {
self.defaults = defaults
self.calendar = calendar
}

var days: Int {
defaults.integer(forKey: Keys.days)
}

func recordUse(at date: Date) {
let day = dayStamp(of: date)
guard defaults.string(forKey: Keys.lastDay) != day else { return }
defaults.set(day, forKey: Keys.lastDay)
defaults.set(days + 1, forKey: Keys.days)
}

private func dayStamp(of date: Date) -> String {
let parts = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
}
}
Loading
Loading