From c41e3fb4176dfd7a00f7d35698197e97ca77c869 Mon Sep 17 00:00:00 2001
From: "prompt.ac/@jeffrey"
Date: Thu, 1 Oct 2026 16:47:34 -0700
Subject: [PATCH] Prepare AC iOS 1.2 with measured foreground usage
---
.../project.pbxproj | 24 +-
apple/aesthetic.computer/ContentView.swift | 31 ++-
apple/aesthetic.computer/LaunchPing.swift | 233 +++++++++++++++---
.../aesthetic.computer/PrivacyInfo.xcprivacy | 54 ++++
.../Settings.bundle/Root.plist | 11 +
.../aesthetic_computerApp.swift | 2 -
apple/fastlane/Fastfile | 13 +-
.../metadata/ios/en-US/release_notes.txt | 2 +-
apple/tests/AppUsageChecks.swift | 48 ++++
system/backend/metrics-daily.mjs | 14 +-
system/backend/native-usage.mjs | 86 +++++++
system/netlify/functions/app-session.mjs | 42 ++++
system/public/network-privacy.html | 10 +
system/tests/native-usage.test.mjs | 78 ++++++
toolchain/analytics/VISITS.md | 27 ++
toolchain/analytics/ios-usage-report.mjs | 11 +
toolchain/mcp/analytics-mcp.mjs | 15 ++
17 files changed, 644 insertions(+), 57 deletions(-)
create mode 100644 apple/aesthetic.computer/PrivacyInfo.xcprivacy
create mode 100644 apple/aesthetic.computer/Settings.bundle/Root.plist
create mode 100644 apple/tests/AppUsageChecks.swift
create mode 100644 system/backend/native-usage.mjs
create mode 100644 system/netlify/functions/app-session.mjs
create mode 100644 system/tests/native-usage.test.mjs
create mode 100644 toolchain/analytics/ios-usage-report.mjs
diff --git a/apple/aesthetic.computer.xcodeproj/project.pbxproj b/apple/aesthetic.computer.xcodeproj/project.pbxproj
index 08bb5ede7d..6299e5946c 100644
--- a/apple/aesthetic.computer.xcodeproj/project.pbxproj
+++ b/apple/aesthetic.computer.xcodeproj/project.pbxproj
@@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
+ AC1250000000000000000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AC1250000000000000000002 /* PrivacyInfo.xcprivacy */; };
+ AC1250000000000000000003 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = AC1250000000000000000004 /* Settings.bundle */; };
41F5CDC72B2931AF00F7FF87 /* aesthetic_computerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41F5CDC62B2931AF00F7FF87 /* aesthetic_computerApp.swift */; };
AC1A0C012E9A000000000001 /* LaunchPing.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1A0C012E9A000000000002 /* LaunchPing.swift */; };
41F5CDC92B2931AF00F7FF87 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41F5CDC82B2931AF00F7FF87 /* ContentView.swift */; };
@@ -44,6 +46,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ AC1250000000000000000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; };
+ AC1250000000000000000004 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = wrapper.plug-in; path = Settings.bundle; sourceTree = ""; };
41CFB5D12B2A5D8D001954C9 /* aesthetic-computer-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "aesthetic-computer-Info.plist"; sourceTree = SOURCE_ROOT; };
41F5CDC32B2931AF00F7FF87 /* aesthetic.computer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = aesthetic.computer.app; sourceTree = BUILT_PRODUCTS_DIR; };
41F5CDC62B2931AF00F7FF87 /* aesthetic_computerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = aesthetic_computerApp.swift; sourceTree = ""; };
@@ -108,6 +112,8 @@
41F5CDC62B2931AF00F7FF87 /* aesthetic_computerApp.swift */,
41F5CDC82B2931AF00F7FF87 /* ContentView.swift */,
AC1A0C012E9A000000000002 /* LaunchPing.swift */,
+ AC1250000000000000000002 /* PrivacyInfo.xcprivacy */,
+ AC1250000000000000000004 /* Settings.bundle */,
41F5CDCA2B2931B000F7FF87 /* Assets.xcassets */,
41F5CDCC2B2931B000F7FF87 /* Preview Content */,
);
@@ -228,6 +234,8 @@
files = (
41F5CDCE2B2931B000F7FF87 /* Preview Assets.xcassets in Resources */,
41F5CDCB2B2931B000F7FF87 /* Assets.xcassets in Resources */,
+ AC1250000000000000000001 /* PrivacyInfo.xcprivacy in Resources */,
+ AC1250000000000000000003 /* Settings.bundle in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -410,7 +418,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
"CODE_SIGN_ENTITLEMENTS[sdk=*]" = aesthetic.computer/aesthetic.computer.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 4;
+ CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\"";
DEVELOPMENT_TEAM = FB5948YR3S;
ENABLE_PREVIEWS = YES;
@@ -428,7 +436,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.1;
+ MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -445,7 +453,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "aesthetic.computer/aesthetic.computer-release.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 4;
+ CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\"";
DEVELOPMENT_TEAM = FB5948YR3S;
ENABLE_PREVIEWS = YES;
@@ -463,7 +471,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.1;
+ MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -477,7 +485,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 4;
+ CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = FB5948YR3S;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = aesthetic/Info.plist;
@@ -489,7 +497,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.1;
+ MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer.aesthetic;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -504,7 +512,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 4;
+ CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = FB5948YR3S;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = aesthetic/Info.plist;
@@ -516,7 +524,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.1;
+ MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer.aesthetic;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/apple/aesthetic.computer/ContentView.swift b/apple/aesthetic.computer/ContentView.swift
index 657b804940..f8fa2aa4b2 100644
--- a/apple/aesthetic.computer/ContentView.swift
+++ b/apple/aesthetic.computer/ContentView.swift
@@ -149,6 +149,7 @@ class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate, WKNavigationD
// MARK: navigation
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
+ LaunchPing.loaded(false)
// Only watchdog real network loads; offline.html doesn't have JS that
// can heartbeat back, so we'd false-alarm on the static offline page.
if let url = webView.url, url.scheme == "https" || url.scheme == "http" {
@@ -195,6 +196,15 @@ class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate, WKNavigationD
bootStatus?.heartbeat()
case "boot:ready":
bootStatus?.heartbeat(ready: true)
+ if message.frameInfo.isMainFrame, message.frameInfo.securityOrigin.protocol == "https",
+ message.frameInfo.securityOrigin.host == "aesthetic.computer" {
+ LaunchPing.loaded(true)
+ }
+ case "usage:canvas":
+ if message.frameInfo.isMainFrame, message.frameInfo.securityOrigin.protocol == "https",
+ message.frameInfo.securityOrigin.host == "aesthetic.computer" {
+ LaunchPing.interacted()
+ }
case "reload":
bootStatus?.requestReload()
case "reload-online":
@@ -255,6 +265,18 @@ struct WebView: UIViewRepresentable {
config.userContentController.addUserScript(userScript)
config.userContentController.add(context.coordinator, name: "iOSAppLog")
config.userContentController.add(context.coordinator, name: "iOSApp")
+ // One boolean for a trusted canvas touch. No keys, coordinates or URLs.
+ config.userContentController.addUserScript(WKUserScript(source: """
+ (() => {
+ let sent = false;
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) sent = false; });
+ document.addEventListener('pointerdown', e => {
+ if (sent || !e.isTrusted || !(e.target instanceof HTMLCanvasElement)) return;
+ sent = true;
+ window.webkit.messageHandlers.iOSApp.postMessage(JSON.stringify({type:'usage:canvas'}));
+ }, {capture:true, passive:true});
+ })();
+ """, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// đź§ą Wipe every cache surface that has been observed to keep stale
// /aesthetic.computer/*.mjs (boot, bios, disk, ...) alive between
@@ -375,6 +397,7 @@ struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var lastBackgroundedAt: Date? = nil
@State private var lastForceLiveTrigger: Int = 0
+ @State private var usageScene = UUID()
private let liveURL = "https://aesthetic.computer"
private var offlineURL: String {
@@ -415,6 +438,8 @@ struct ContentView: View {
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 24 : 0)
.background(Color(red: grey, green: grey, blue: grey))
.ignoresSafeArea(.keyboard, edges: .bottom)
+ .onAppear { handleScenePhase(scenePhase) }
+ .onDisappear { LaunchPing.remove(scene: usageScene) }
.onChange(of: scenePhase) { newPhase in
handleScenePhase(newPhase)
}
@@ -441,8 +466,10 @@ struct ContentView: View {
private func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
+ LaunchPing.inactive(scene: usageScene, background: true)
lastBackgroundedAt = Date()
case .active:
+ LaunchPing.active(scene: usageScene)
// After a long sleep the WebView often holds a stale runtime
// (sockets timed out, modules half-loaded). A fresh load is
// cheaper than debugging which subsystem gave up.
@@ -453,7 +480,9 @@ struct ContentView: View {
}
}
lastBackgroundedAt = nil
- default:
+ case .inactive:
+ LaunchPing.inactive(scene: usageScene, background: false)
+ @unknown default:
break
}
}
diff --git a/apple/aesthetic.computer/LaunchPing.swift b/apple/aesthetic.computer/LaunchPing.swift
index e5c44a715b..81b0db9107 100644
--- a/apple/aesthetic.computer/LaunchPing.swift
+++ b/apple/aesthetic.computer/LaunchPing.swift
@@ -1,46 +1,209 @@
-// LaunchPing — one POST to aesthetic.computer/api/app-open per launch, so we
-// can count opens and new installs. What goes out: the app's id, its version,
-// the platform (ios / ipados / mac), a random install id minted on the first
-// launch, and whether this is that first launch. Nothing names the person,
-// their device or their account (see toolchain/analytics/VISITS.md).
-// The id lives in UserDefaults rather than the Keychain on purpose: deleting
-// the app deletes it. Off in DEBUG builds and when "acLaunchPingDisabled" is set.
+// AC 1.2: cumulative snapshots of foreground sessions. No account, APNs token,
+// URL or input text. First observed is not an App Store download.
import Foundation
#if canImport(UIKit)
import UIKit
#endif
-@MainActor enum LaunchPing {
- private static var sent = false
-
- static func send(_ app: String) {
- #if !DEBUG
- let defaults = UserDefaults.standard
- guard !sent, !defaults.bool(forKey: "acLaunchPingDisabled") else { return }
- sent = true
- let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "")
- .filter { "0123456789.".contains($0) }
- guard !version.isEmpty else { return }
- let fresh = defaults.string(forKey: "acInstallID") == nil
- if fresh { defaults.set(UUID().uuidString.lowercased(), forKey: "acInstallID") }
- let body: [String: Any] = ["app": app, "version": version, "platform": platform,
- "install": defaults.string(forKey: "acInstallID") ?? "", "fresh": fresh]
- var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/app-open")!,
- timeoutInterval: 10)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- request.httpBody = try? JSONSerialization.data(withJSONObject: body)
- let session = URLSession(configuration: .ephemeral)
- session.dataTask(with: request) { _, _, _ in }.resume() // fire and forget
- session.finishTasksAndInvalidate()
- #endif
+struct AppUsageSample: Codable, Equatable {
+ private(set) var schema = 1
+ private(set) var app = "aestheticcomputer"
+ let version: String
+ let build: String
+ let platform: String
+ let install: String
+ let session: String
+ let startedAt: String
+ let firstObserved: Bool
+ var activeSeconds = 0
+ var ready = false
+ var interacted = false
+}
+
+enum AppUsagePhase { case active, inactive, background }
+struct AppUsageScenes {
+ var phases: [UUID: AppUsagePhase] = [:]
+ var phase: AppUsagePhase {
+ if phases.values.contains(.active) { return .active }
+ if phases.values.contains(.inactive) { return .inactive }
+ return .background
+ }
+}
+
+// Foundation-only state machine: tests need not replace the App Store app.
+@MainActor final class AppUsageMeter {
+ private let defaults: UserDefaults
+ private let version: String, build: String, platform: String
+ private var activeSince: TimeInterval?
+ private var seconds: TimeInterval = 0
+ private var backgrounded = true
+ private var runtimeReady = false
+ private(set) var current: AppUsageSample?
+ private(set) var pending: [AppUsageSample]
+
+ init(defaults: UserDefaults, version: String, build: String, platform: String) {
+ self.defaults = defaults; self.version = version; self.build = build; self.platform = platform
+ pending = defaults.data(forKey: "acUsagePending")
+ .flatMap { try? JSONDecoder().decode([AppUsageSample].self, from: $0) } ?? []
+ }
+
+ func activate(now: Date = Date(), uptime: TimeInterval = ProcessInfo.processInfo.systemUptime) {
+ guard activeSince == nil else { return }
+ if backgrounded || current == nil {
+ let install = defaults.string(forKey: "acInstallID") ?? UUID().uuidString.lowercased()
+ defaults.set(install, forKey: "acInstallID")
+ let session = UUID().uuidString.lowercased()
+ if defaults.string(forKey: "acUsageFirstSession") == nil {
+ defaults.set(session, forKey: "acUsageFirstSession")
+ }
+ current = AppUsageSample(version: version, build: build, platform: platform,
+ install: install, session: session, startedAt: ISO8601DateFormatter().string(from: now),
+ firstObserved: defaults.string(forKey: "acUsageFirstSession") == session,
+ ready: runtimeReady)
+ seconds = 0
+ }
+ backgrounded = false; activeSince = uptime
+ checkpoint(now: now, uptime: uptime)
+ }
+
+ func pause(background: Bool, now: Date = Date(), uptime: TimeInterval = ProcessInfo.processInfo.systemUptime) {
+ checkpoint(now: now, uptime: uptime)
+ activeSince = nil
+ if background { backgrounded = true }
+ }
+
+ func loaded(_ ready: Bool) {
+ runtimeReady = ready
+ if ready { current?.ready = true }
+ }
+
+ func interact() {
+ guard activeSince != nil else { return }
+ current?.interacted = true
+ }
+
+ func checkpoint(now: Date = Date(), uptime: TimeInterval = ProcessInfo.processInfo.systemUptime) {
+ if let since = activeSince {
+ seconds = min(86400, seconds + max(0, uptime - since))
+ activeSince = uptime
+ current?.activeSeconds = Int(seconds)
+ }
+ if let sample = current {
+ pending.removeAll { $0.session == sample.session }
+ pending.append(sample)
+ }
+ let cutoff = now.addingTimeInterval(-7 * 86400), formatter = ISO8601DateFormatter()
+ pending = Array(pending.filter { (formatter.date(from: $0.startedAt) ?? .distantPast) >= cutoff }.suffix(128))
+ persist()
+ }
+
+ func acknowledge(_ sent: AppUsageSample) {
+ // An older response must not discard a newer checkpoint.
+ pending.removeAll { $0 == sent }; persist()
+ }
+
+ func discard() {
+ pending.removeAll(); current = nil; activeSince = nil; seconds = 0; backgrounded = true; persist()
+ }
+
+ private func persist() {
+ if let data = try? JSONEncoder().encode(pending) { defaults.set(data, forKey: "acUsagePending") }
}
+}
- private static var platform: String {
- #if os(macOS) || targetEnvironment(macCatalyst)
- return "mac"
+#if canImport(UIKit)
+@MainActor enum LaunchPing {
+ private static let defaults = UserDefaults.standard
+ private static let meter = AppUsageMeter(defaults: defaults,
+ version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0",
+ build: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0",
+ platform: UIDevice.current.userInterfaceIdiom == .pad ? "ipados" : "ios")
+ private static var timer: Timer?
+ private static var task: URLSessionDataTask?
+ private static var requestID = UUID()
+ private static var scenes = AppUsageScenes()
+ private static let transport = URLSession(configuration: .ephemeral)
+ private static var enabled: Bool {
+ #if DEBUG
+ return false
#else
- return UIDevice.current.userInterfaceIdiom == .pad ? "ipados" : "ios"
+ return !defaults.bool(forKey: "acLaunchPingDisabled") &&
+ (defaults.object(forKey: "acUsageEnabled") as? Bool ?? true)
#endif
}
+
+ static func active(scene: UUID) {
+ scenes.phases[scene] = .active
+ refreshPhase()
+ }
+
+ static func inactive(scene: UUID, background: Bool) {
+ scenes.phases[scene] = background ? .background : .inactive
+ refreshPhase()
+ }
+
+ static func remove(scene: UUID) {
+ scenes.phases.removeValue(forKey: scene)
+ refreshPhase()
+ }
+
+ private static func refreshPhase() {
+ guard enabled else { stop(); return }
+ if scenes.phase == .active {
+ meter.activate()
+ if timer == nil {
+ timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in
+ Task { @MainActor in checkpoint() }
+ }
+ }
+ } else {
+ meter.pause(background: scenes.phase == .background)
+ timer?.invalidate(); timer = nil
+ }
+ flush()
+ }
+
+ static func loaded(_ ready: Bool) {
+ guard enabled else { return }
+ meter.loaded(ready)
+ if ready { checkpoint() }
+ }
+
+ static func interacted() {
+ guard enabled, meter.current?.interacted != true else { return }
+ meter.interact(); checkpoint()
+ }
+
+ private static func checkpoint() {
+ guard enabled else { stop(); return }
+ meter.checkpoint(); flush()
+ }
+
+ private static func stop() {
+ timer?.invalidate(); timer = nil; requestID = UUID(); task?.cancel(); task = nil; meter.discard()
+ }
+
+ private static func flush() {
+ guard enabled, task == nil, let sample = meter.pending.first,
+ let body = try? JSONEncoder().encode(sample) else { return }
+ var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/app-session")!, timeoutInterval: 10)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+ let sendingID = UUID(); requestID = sendingID
+ task = transport.dataTask(with: request) { _, response, _ in
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
+ Task { @MainActor in
+ guard requestID == sendingID else { return }
+ task = nil
+ guard enabled else { meter.discard(); return }
+ if (200..<300).contains(status) || status == 400 {
+ meter.acknowledge(sample); flush()
+ }
+ // Retry offline/429/5xx responses at the next checkpoint.
+ }
+ }
+ task?.resume()
+ }
}
+#endif
diff --git a/apple/aesthetic.computer/PrivacyInfo.xcprivacy b/apple/aesthetic.computer/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000000..41b9cad80a
--- /dev/null
+++ b/apple/aesthetic.computer/PrivacyInfo.xcprivacy
@@ -0,0 +1,54 @@
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyCollectedDataTypes
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeDeviceID
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAnalytics
+
+ NSPrivacyCollectedDataTypeTracking
+
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeProductInteraction
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAnalytics
+
+ NSPrivacyCollectedDataTypeTracking
+
+
+
+ NSPrivacyTracking
+
+
+
diff --git a/apple/aesthetic.computer/Settings.bundle/Root.plist b/apple/aesthetic.computer/Settings.bundle/Root.plist
new file mode 100644
index 0000000000..8864e5ce9f
--- /dev/null
+++ b/apple/aesthetic.computer/Settings.bundle/Root.plist
@@ -0,0 +1,11 @@
+
+
+
+ PreferenceSpecifiers
+ TypePSGroupSpecifier
+ FooterTextShare counts of app opens, time in the foreground, successful loads and canvas interactions. A random identifier is kept in this app’s settings. Counts are not linked to your account. First observed can include an update or reinstall. Turn this off to stop sending usage and discard unsent counts.
+ TypePSToggleSwitchSpecifier
+ TitleShare app usageKeyacUsageEnabled
+ DefaultValue
+
+
diff --git a/apple/aesthetic.computer/aesthetic_computerApp.swift b/apple/aesthetic.computer/aesthetic_computerApp.swift
index 506c0d3738..69fba7011e 100644
--- a/apple/aesthetic.computer/aesthetic_computerApp.swift
+++ b/apple/aesthetic.computer/aesthetic_computerApp.swift
@@ -32,8 +32,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
- LaunchPing.send("aestheticcomputer")
-
// đź”” Register for remote notifications. This shows a permission dialog on first run, to
// show the dialog at a more appropriate time move this registration accordingly.
UNUserNotificationCenter.current().delegate = self
diff --git a/apple/fastlane/Fastfile b/apple/fastlane/Fastfile
index 1e8f7c29d6..bafbdfa2ad 100644
--- a/apple/fastlane/Fastfile
+++ b/apple/fastlane/Fastfile
@@ -20,8 +20,8 @@ API_ISSUER = "69a6de78-fa3c-47e3-e053-5b8c7c11a4d1"
API_KEY_PATH = File.expand_path("~/.appstoreconnect/private_keys/AuthKey_S4TQKG6U99.p8")
BUNDLE = "aesthetic.computer"
TEAM = "FB5948YR3S"
-VERSION = ENV.fetch("AC_IOS_VERSION", "1.1")
-BUILD_NUMBER = ENV.fetch("AC_IOS_BUILD_NUMBER", "4")
+VERSION = ENV.fetch("AC_IOS_VERSION", "1.2")
+BUILD_NUMBER = ENV.fetch("AC_IOS_BUILD_NUMBER", "5")
# Everything is anchored to this Fastfile's own location so the lanes resolve
# no matter where fastlane is invoked from.
@@ -103,12 +103,9 @@ platform :ios do
# opening the portal. The same credentials go to the export step, which
# is where the re-signing actually happens.
lane :build do
- # Xcode 15+ refuses to archive for any iOS destination until the iOS
- # platform (the simulator runtime, ~8 GB) is installed, even though the
- # device SDK ships inside Xcode.app. Xcode point updates have dropped it
- # before (26.6 did), so check rather than discover it ten minutes in.
- runtimes = sh("xcrun simctl runtime list 2>/dev/null | grep -c iOS || true", log: false).strip.to_i
- UI.user_error!("Xcode has no iOS platform installed — run `xcodebuild -downloadPlatform iOS` first (~8 GB)") if runtimes.zero?
+ # A device archive needs the iPhoneOS SDK, not an installed simulator.
+ sdk = sh("xcrun --sdk iphoneos --show-sdk-path", log: false).strip
+ UI.user_error!("Xcode has no iPhoneOS SDK installed") unless File.directory?(sdk)
build_ios_app(
project: PROJECT,
scheme: SCHEME,
diff --git a/apple/fastlane/metadata/ios/en-US/release_notes.txt b/apple/fastlane/metadata/ios/en-US/release_notes.txt
index a47454e90c..b56144ac6e 100644
--- a/apple/fastlane/metadata/ios/en-US/release_notes.txt
+++ b/apple/fastlane/metadata/ios/en-US/release_notes.txt
@@ -1 +1 @@
-Recovery from hung loads: reload from a stuck boot screen, automatic retry when the network reconnects, and pull down to refresh. Notifications now arrive directly from Apple, so the app is smaller and starts faster.
\ No newline at end of file
+Adds optional app usage counts to help us understand successful loads and canvas use. Manage “Share app usage” in iOS Settings → Apps → aesthetic. Usage counts are separate from your account and never include your paintings, messages or typed input.
diff --git a/apple/tests/AppUsageChecks.swift b/apple/tests/AppUsageChecks.swift
new file mode 100644
index 0000000000..33a64dccc1
--- /dev/null
+++ b/apple/tests/AppUsageChecks.swift
@@ -0,0 +1,48 @@
+import Foundation
+
+@main struct AppUsageChecks {
+ @MainActor static func main() throws {
+ let suite = "ac.usage.test." + UUID().uuidString
+ let defaults = UserDefaults(suiteName: suite)!
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let time = Date(timeIntervalSince1970: 1_790_856_000)
+ let windowA = UUID(), windowB = UUID()
+ var scenes = AppUsageScenes()
+ scenes.phases[windowA] = .active; scenes.phases[windowB] = .active
+ scenes.phases[windowA] = .background
+ precondition(scenes.phase == .active, "One background window must not pause another")
+ scenes.phases[windowB] = .inactive; precondition(scenes.phase == .inactive)
+ scenes.phases.removeValue(forKey: windowB); precondition(scenes.phase == .background)
+ let meter = AppUsageMeter(defaults: defaults, version: "1.2", build: "5", platform: "ios")
+ meter.activate(now: time, uptime: 100)
+ let first = meter.current!
+ precondition(first.firstObserved && first.activeSeconds == 0)
+ meter.activate(now: time, uptime: 101)
+ precondition(meter.current!.session == first.session, "Duplicate active notifications must not create opens")
+ meter.loaded(true); meter.interact()
+ meter.pause(background: false, now: time, uptime: 112)
+ precondition(meter.current!.activeSeconds == 12)
+ meter.acknowledge(first)
+ precondition(meter.pending.count == 1, "Old acknowledgements must retain newer data")
+ meter.activate(now: time, uptime: 200)
+ precondition(meter.current!.session == first.session, "Control Center must not create an open")
+ meter.pause(background: true, now: time, uptime: 208)
+ precondition(meter.current!.activeSeconds == 20, "Inactive time must not count")
+ meter.activate(now: time.addingTimeInterval(200), uptime: 300)
+ precondition(meter.current!.session != first.session && !meter.current!.firstObserved)
+ precondition(meter.current!.install == first.install && !meter.current!.interacted && meter.current!.ready)
+ precondition(meter.pending.count == 2)
+ let reopened = AppUsageMeter(defaults: defaults, version: "1.2", build: "5", platform: "ios")
+ precondition(reopened.pending == meter.pending, "Offline snapshots must survive process death")
+ reopened.activate(now: time.addingTimeInterval(300), uptime: 400)
+ precondition(reopened.current!.install == first.install && !reopened.current!.firstObserved)
+ precondition(reopened.pending.count == 3)
+ reopened.acknowledge(reopened.pending[0]); precondition(reopened.pending.count == 2)
+ reopened.checkpoint(now: time.addingTimeInterval(8 * 86400), uptime: 400)
+ precondition(reopened.pending.isEmpty, "Expired offline snapshots must be dropped")
+ meter.discard(); precondition(meter.pending.isEmpty && meter.current == nil)
+ let object = try JSONSerialization.jsonObject(with: JSONEncoder().encode(first)) as! [String: Any]
+ precondition(Set(object.keys) == Set(["schema", "app", "version", "build", "platform", "install", "session", "startedAt", "firstObserved", "activeSeconds", "ready", "interacted"]))
+ print("App usage lifecycle, retry, persistence, expiry and payload checks passed")
+ }
+}
diff --git a/system/backend/metrics-daily.mjs b/system/backend/metrics-daily.mjs
index 4e884e997b..2551b8a1cf 100644
--- a/system/backend/metrics-daily.mjs
+++ b/system/backend/metrics-daily.mjs
@@ -7,6 +7,7 @@
// are read by toolchain/mcp/analytics-mcp.mjs, not here.
import { VISIT_ACTIONS, VISIT_DEPTHS } from "../public/aesthetic.computer/lib/visit-model.mjs";
+import { nativeUsageDaily } from "./native-usage.mjs";
export const METRICS_DAILY_COLLECTION = "metrics-daily";
const DAY = 86400000;
@@ -47,7 +48,8 @@ async function foldDay(db, start) {
return { day, generatedAt: new Date(),
visits: tally(visits, ["visits", "interacted", "engaged", "automated", "actionVisits", ...VISIT_ACTIONS, ...VISIT_DEPTHS.map(seconds => `interacted${seconds}`)]),
downloads: tally(downloads, ["downloads", "places", "automated"]),
- opens: tally(opens, ["active", "opens", "fresh"]) };
+ opens: tally(opens, ["active", "opens", "fresh"]),
+ nativeUsage: await nativeUsageDaily(db, start, end) };
}
// Writes every finished day in the backfill window that has no row yet.
@@ -58,7 +60,15 @@ export async function rollupMissingDays(db, now = new Date()) {
const written = [];
for (let back = BACKFILL_DAYS; back >= 1; back--) {
const start = new Date(+today - back * DAY), day = start.toISOString().slice(0, 10);
- if (day < FIRST_DAY || have.has(day)) continue;
+ if (day < FIRST_DAY) continue;
+ if (have.has(day)) {
+ // iOS persists offline snapshots for seven days. Refresh only its
+ // counts while leaving already-folded web/desktop measurements intact.
+ if (back <= 8) await collection.updateOne({ _id: day }, { $set: {
+ nativeUsage: await nativeUsageDaily(db, start, new Date(+start + DAY)),
+ } });
+ continue;
+ }
await collection.updateOne({ _id: day }, { $setOnInsert: await foldDay(db, start) }, { upsert: true });
written.push(day);
}
diff --git a/system/backend/native-usage.mjs b/system/backend/native-usage.mjs
new file mode 100644
index 0000000000..ab14913236
--- /dev/null
+++ b/system/backend/native-usage.mjs
@@ -0,0 +1,86 @@
+// Cumulative iOS session snapshots. Retries update one row, never add an open.
+export const NATIVE_USAGE_COLLECTION = "native-app-sessions";
+const DAY = 86400000;
+const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
+const VERSION = /^\d+(?:\.\d+){0,3}$/;
+
+export function validateNativeUsage(body, now = new Date()) {
+ if (!body || body.schema !== 1 || body.app !== "aestheticcomputer" ||
+ !["ios", "ipados"].includes(body.platform) ||
+ typeof body.version !== "string" || !VERSION.test(body.version) ||
+ typeof body.build !== "string" || !/^\d{1,9}$/.test(body.build) ||
+ typeof body.install !== "string" || !UUID.test(body.install) ||
+ typeof body.session !== "string" || !UUID.test(body.session) ||
+ typeof body.startedAt !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/.test(body.startedAt) ||
+ !Number.isInteger(body.activeSeconds) || body.activeSeconds < 0 || body.activeSeconds > 86400 ||
+ ![body.firstObserved, body.ready, body.interacted].every(x => typeof x === "boolean")) return null;
+ const startedAt = new Date(body.startedAt);
+ if (!Number.isFinite(+startedAt) || +startedAt < +now - 7 * DAY || +startedAt > +now + 60000 ||
+ body.activeSeconds > Math.max(0, (+now - +startedAt) / 1000) + 60) return null;
+ return { schema: 1, app: body.app, platform: body.platform, version: body.version, build: body.build,
+ install: body.install.toLowerCase(), session: body.session.toLowerCase(), startedAt,
+ activeSeconds: body.activeSeconds, firstObserved: body.firstObserved, ready: body.ready, interacted: body.interacted };
+}
+
+export function nativeUsageWrite(value, now = new Date()) {
+ const { app, install, session, platform, version, build, startedAt, firstObserved } = value;
+ return {
+ id: `${app}:${install}:${session}`,
+ update: {
+ $setOnInsert: { app, install, session, platform, version, build, startedAt, firstObserved,
+ day: startedAt.toISOString().slice(0, 10), expiresAt: new Date(+startedAt + 35 * DAY) },
+ $max: { activeSeconds: value.activeSeconds, ready: value.ready, interacted: value.interacted, receivedAt: now },
+ },
+ };
+}
+
+// Sessions spanning midnight are attributed to the day they opened. Installation
+// means first observed by this telemetry version, not an App Store download.
+export async function nativeUsageReport(db, start, end = new Date()) {
+ const sessions = await db.collection(NATIVE_USAGE_COLLECTION).aggregate([
+ { $match: { startedAt: { $gte: start, $lt: end } } },
+ { $group: { _id: { app: "$app", install: "$install", day: "$day" },
+ opens: { $sum: 1 }, activeSeconds: { $sum: "$activeSeconds" },
+ loaded: { $sum: { $cond: ["$ready", 1, 0] } },
+ interacted: { $sum: { $cond: ["$interacted", 1, 0] } },
+ engaged: { $sum: { $cond: [{ $and: ["$ready", "$interacted", { $gte: ["$activeSeconds", 10] }] }, 1, 0] } },
+ firstObserved: { $max: "$firstObserved" }, platform: { $first: "$platform" }, versions: { $addToSet: "$version" },
+ } },
+ { $sort: { "_id.day": 1 } },
+ ], { maxTimeMS: 20000 }).toArray();
+ return summarizeNativeUsage(sessions, start, end);
+}
+
+export function summarizeNativeUsage(rows, start, end) {
+ const apps = {}, installs = new Map();
+ const empty = () => ({ activeInstalls: 0, opens: 0, firstObservedInstalls: 0, activeSeconds: 0,
+ loadedSessions: 0, interactedSessions: 0, engagedSessions: 0 });
+ for (const row of rows) {
+ const { app, install, day } = row._id;
+ const stats = apps[app] ??= { ...empty(), returningInstalls: 0, daily: {}, platforms: {} };
+ const daily = stats.daily[day] ??= empty();
+ const key = `${app}:${install}`;
+ let seen = installs.get(key);
+ if (!seen) { seen = { days: new Set(), first: false }; installs.set(key, seen); stats.activeInstalls++; }
+ if (!seen.days.has(day)) {
+ seen.days.add(day); daily.activeInstalls++;
+ if (seen.days.size === 2) stats.returningInstalls++;
+ }
+ if (row.firstObserved) {
+ daily.firstObservedInstalls++;
+ if (!seen.first) { seen.first = true; stats.firstObservedInstalls++; }
+ }
+ for (const [target, source] of Object.entries({ opens: "opens", activeSeconds: "activeSeconds",
+ loadedSessions: "loaded", interactedSessions: "interacted", engagedSessions: "engaged" })) {
+ stats[target] += row[source] || 0; daily[target] += row[source] || 0;
+ }
+ stats.platforms[row.platform] = (stats.platforms[row.platform] || 0) + row.opens;
+ }
+ return { format: "ac.native-usage.v1", start, end, apps,
+ note: "AC iOS 1.2+ only. Opens are foreground sessions, not background pushes or brief system interruptions. Active time is foreground time, not proof of attention; engaged requires a successful load, canvas interaction and 10 seconds. First observed includes upgrades, restores and reinstalls. Returning means activity on 2+ UTC days within this window. Offline snapshots can arrive 7 days late. Sessions belong to their start day. No account or device identity is collected." };
+}
+
+export async function nativeUsageDaily(db, start, end) {
+ const report = await nativeUsageReport(db, start, end);
+ return Object.fromEntries(Object.entries(report.apps).map(([app, { daily, platforms, returningInstalls, ...counts }]) => [app, counts]));
+}
diff --git a/system/netlify/functions/app-session.mjs b/system/netlify/functions/app-session.mjs
new file mode 100644
index 0000000000..0fd213e766
--- /dev/null
+++ b/system/netlify/functions/app-session.mjs
@@ -0,0 +1,42 @@
+// First-party AC iOS usage; payloads contain counters and random app-local IDs.
+import { connect } from "../../backend/database.mjs";
+import { respond } from "../../backend/http.mjs";
+import { createHmac, randomBytes } from "node:crypto";
+import { NATIVE_USAGE_COLLECTION, validateNativeUsage, nativeUsageWrite } from "../../backend/native-usage.mjs";
+
+const salt = randomBytes(32), rates = new Map();
+let indexes;
+export async function handler(event) {
+ const headers = { "Cache-Control": "no-store" };
+ if (event.httpMethod === "OPTIONS") return respond(204, "", headers);
+ if (event.httpMethod !== "POST") return respond(405, { error: "POST required" }, headers);
+ let body = event.body;
+ if (typeof body === "string") {
+ if (body.length > 1024) return respond(400, { error: "Invalid session" }, headers);
+ try { body = JSON.parse(body); } catch { return respond(400, { error: "Invalid session" }, headers); }
+ }
+ const value = validateNativeUsage(body);
+ if (!value) return respond(400, { error: "Invalid session" }, headers);
+ const now = Date.now();
+ for (const [key, row] of rates) if (row.until <= now) rates.delete(key);
+ const address = event.headers?.["cf-connecting-ip"] || event.headers?.["x-forwarded-for"] || "unknown";
+ const key = createHmac("sha256", salt).update(address).digest("hex");
+ let rate = rates.get(key);
+ if (!rate) {
+ if (rates.size >= 10000) return respond(429, "", headers);
+ rate = { count: 0, until: now + 60000 }; rates.set(key, rate);
+ }
+ if (++rate.count > 120) return respond(429, "", headers);
+ try {
+ const { db } = await connect(), collection = db.collection(NATIVE_USAGE_COLLECTION);
+ indexes ||= Promise.all([
+ collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
+ collection.createIndex({ startedAt: 1, app: 1 }),
+ ]).catch(e => { indexes = null; throw e; });
+ await indexes;
+ const { id, update } = nativeUsageWrite(value);
+ try { await collection.updateOne({ _id: id }, update, { upsert: true }); }
+ catch (e) { if (e.code !== 11000) throw e; await collection.updateOne({ _id: id }, update); }
+ return respond(204, "", headers);
+ } catch { return respond(503, { error: "Usage counts unavailable" }, headers); }
+}
diff --git a/system/public/network-privacy.html b/system/public/network-privacy.html
index a8f4f926c6..553938cb96 100644
--- a/system/public/network-privacy.html
+++ b/system/public/network-privacy.html
@@ -65,6 +65,16 @@ an account, device, advertising identifier or address, and it is not shared
with anyone. We use it to count how many installs open each day. Records are
kept for 35 days, and a coarse country from our network provider is kept with
them.
+Aesthetic Computer for iOS 1.2 adds foreground session counts, seconds spent
+active, successful-load and canvas-interaction flags. It sends cumulative
+snapshots so retries do not count as additional opens. The random install ID
+is kept in app settings, survives updates and may be restored with a backup;
+it is not an account or hardware identifier. “First observed” can therefore
+include an existing installation updating to this version. Offline snapshots
+are kept for at most seven days on the device; server session records expire
+after 35 days, while daily totals contain counts only. In iOS Settings → Apps
+→ aesthetic, turn off “Share app usage” to stop sending and discard queued
+snapshots. Debug builds do not send these counts.
This describes the shared visit tracker. Existing account services, product
analytics, purchases and public gameplay recordings have separate purposes
and are not joined into its records.
diff --git a/system/tests/native-usage.test.mjs b/system/tests/native-usage.test.mjs
new file mode 100644
index 0000000000..9697f51118
--- /dev/null
+++ b/system/tests/native-usage.test.mjs
@@ -0,0 +1,78 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import { validateNativeUsage, nativeUsageWrite, summarizeNativeUsage } from "../backend/native-usage.mjs";
+import { handler } from "../netlify/functions/app-session.mjs";
+import { rollupMissingDays } from "../backend/metrics-daily.mjs";
+
+const now = new Date("2026-10-01T12:00:30Z");
+const sample = () => ({ schema: 1, app: "aestheticcomputer", platform: "ios", version: "1.2", build: "5",
+ install: randomUUID(), session: randomUUID(), startedAt: "2026-10-01T12:00:00Z",
+ firstObserved: true, ready: true, interacted: true, activeSeconds: 20 });
+
+test("collector bounds dates, time and identities and discards private extras", () => {
+ const input = sample();
+ for (const bad of [{ platform: "mac" }, { schema: 2 }, { session: "bad" }, { activeSeconds: -1 },
+ { activeSeconds: 1000 }, { activeSeconds: 1.5 }, { ready: "true" },
+ { startedAt: "2026-09-01T12:00:00Z" }, { startedAt: "2026-10-02T12:00:00Z" }])
+ assert.equal(validateNativeUsage({ ...input, ...bad }, now), null);
+ const value = validateNativeUsage({ ...input, email: "private", apns: "private", url: "private" }, now);
+ assert.ok(value);
+ assert.ok(!JSON.stringify(nativeUsageWrite(value, now)).includes("private"));
+});
+
+test("offline replay has a stable row, max counters, and event-day retention", () => {
+ const input = sample(), nextDay = new Date(+now + 86400000);
+ const a = nativeUsageWrite(validateNativeUsage(input, now), now);
+ const b = nativeUsageWrite(validateNativeUsage({ ...input, activeSeconds: 25 }, nextDay), nextDay);
+ assert.equal(a.id, b.id);
+ assert.equal(b.update.$setOnInsert.day, "2026-10-01");
+ assert.equal(+b.update.$setOnInsert.expiresAt - +b.update.$setOnInsert.startedAt, 35 * 86400000);
+ assert.equal(b.update.$max.activeSeconds, 25);
+ assert.equal(b.update.$inc, undefined);
+ assert.equal(b.update.$max.ready, true);
+});
+
+test("report counts unique installs and return days without conflating sessions", () => {
+ const row = (install, day, extra = {}) => ({ _id: { app: "aestheticcomputer", install, day },
+ opens: 1, activeSeconds: 20, loaded: 1, interacted: 1, engaged: 1, platform: "ios", ...extra });
+ const report = summarizeNativeUsage([
+ row("a", "2026-09-30", { opens: 2, firstObserved: true }), row("a", "2026-10-01"),
+ row("b", "2026-10-01", { activeSeconds: 0, loaded: 0, interacted: 0, engaged: 0, firstObserved: true }),
+ ], new Date("2026-09-30"), now).apps.aestheticcomputer;
+ assert.equal(report.activeInstalls, 2);
+ assert.equal(report.returningInstalls, 1);
+ assert.equal(report.opens, 4);
+ assert.equal(report.firstObservedInstalls, 2);
+ assert.equal(report.engagedSessions, 2);
+ assert.equal(report.daily["2026-10-01"].activeInstalls, 2);
+});
+
+test("invalid HTTP requests never need a database", async () => {
+ for (const event of [{ httpMethod: "GET" }, { httpMethod: "POST", body: "{" },
+ { httpMethod: "POST", body: "x".repeat(1025) }, { httpMethod: "POST", body: "{}" }])
+ assert.ok((await handler(event)).statusCode >= 400);
+});
+
+test("late iOS snapshots refresh daily totals without rewriting web or legacy app counts", async () => {
+ const updates = [];
+ const days = Array.from({ length: 8 }, (_, n) => `2026-09-${23 + n}`);
+ const db = { collection(name) {
+ if (name === "metrics-daily") return {
+ distinct: async () => days,
+ updateOne: async (filter, update) => updates.push({ filter, update }),
+ };
+ assert.equal(name, "native-app-sessions", "Existing web/desktop totals must not be recomputed");
+ return { aggregate(pipeline) { return { toArray: async () => {
+ const day = pipeline[0].$match.startedAt.$gte.toISOString().slice(0, 10);
+ return day === "2026-09-25" ? [{ _id: { app: "aestheticcomputer", install: "test", day },
+ opens: 2, activeSeconds: 50, loaded: 2, interacted: 1, engaged: 1, firstObserved: true, platform: "ios" }] : [];
+ } }; } };
+ } };
+ await rollupMissingDays(db, new Date("2026-10-01T12:00:00Z"));
+ assert.equal(updates.length, 8);
+ for (const { update } of updates) assert.deepEqual(Object.keys(update.$set), ["nativeUsage"]);
+ const counts = updates.find(x => x.filter._id === "2026-09-25").update.$set.nativeUsage.aestheticcomputer;
+ assert.equal(counts.opens, 2); assert.equal(counts.activeInstalls, 1); assert.equal(counts.activeSeconds, 50);
+ assert.ok(!JSON.stringify(counts).includes("test"));
+});
diff --git a/toolchain/analytics/VISITS.md b/toolchain/analytics/VISITS.md
index a968d65624..8edaedb323 100644
--- a/toolchain/analytics/VISITS.md
+++ b/toolchain/analytics/VISITS.md
@@ -332,3 +332,30 @@ Debug builds, dev Electron and `acLaunchPingDisabled` skip the ping.
Readout: `node --env-file=.env ../toolchain/analytics/opens-report.mjs --days 7`
on lith, or the `app_opens` tool in `toolchain/mcp/analytics-mcp.mjs`.
+
+### AC iOS 1.2
+
+The store's 1.1 (4) predates launch telemetry. Version 1.2 (5) uses
+`/api/app-session` instead of the older launch endpoint. Its cumulative
+snapshots contain only schema/app/version/build/platform, random install and
+session UUIDs, session start, foreground seconds and first-observed,
+successful-load and canvas-interaction flags. A foreground after background
+creates an open; an inactive/active system interruption does not. Background
+push delivery never creates an open. Timers pause while inactive. A session
+is attributed to its opening UTC day, including time across midnight.
+
+Use `ios_usage` in the analytics MCP or `ios-usage-report.mjs --days 7` on Lith.
+Active installs are distinct app-local IDs; returning installs appear on at
+least two days in the selected window. Engaged sessions loaded successfully,
+received a trusted canvas touch and were active for at least ten seconds.
+Foreground time alone does not establish attention. First observed includes
+upgrades to this telemetry version, and backup restore may preserve an ID.
+Apple's downloads report remains the source for acquisition counts.
+
+Snapshots coalesce locally (128 sessions, seven days), survive process death,
+and are acknowledged only after successful delivery. Server `$max` updates
+make replay and reordering idempotent. Raw `native-app-sessions` expire after
+35 days; `metrics-daily.nativeUsage` retains counts and refreshes the recent
+eight days for delayed delivery. No identifiers enter daily rollups or MCP
+responses. Settings → Apps → aesthetic → Share app usage disables sending
+and discards queued snapshots; Debug builds are silent.
diff --git a/toolchain/analytics/ios-usage-report.mjs b/toolchain/analytics/ios-usage-report.mjs
new file mode 100644
index 0000000000..0f4e20b537
--- /dev/null
+++ b/toolchain/analytics/ios-usage-report.mjs
@@ -0,0 +1,11 @@
+#!/usr/bin/env node
+// On Lith: cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/ios-usage-report.mjs --days 7
+import { connect, closePool } from "../../system/backend/database.mjs";
+import { nativeUsageReport } from "../../system/backend/native-usage.mjs";
+const args = process.argv.slice(2), i = args.indexOf("--days");
+const days = i < 0 ? 7 : Number(args[i + 1]);
+if (!Number.isInteger(days) || days < 1 || days > 35) throw new Error("Use --days 1..35");
+const end = new Date(), start = new Date(end.toISOString().slice(0, 10));
+start.setUTCDate(start.getUTCDate() - days + 1);
+try { const { db } = await connect(); console.log(JSON.stringify(await nativeUsageReport(db, start, end), null, 2)); }
+finally { await closePool(); }
diff --git a/toolchain/mcp/analytics-mcp.mjs b/toolchain/mcp/analytics-mcp.mjs
index f68d3a2a3d..bd8d01accc 100644
--- a/toolchain/mcp/analytics-mcp.mjs
+++ b/toolchain/mcp/analytics-mcp.mjs
@@ -161,6 +161,15 @@ async function appOpens({ days = 7 } = {}) {
return { since: report.start, note: report.unit, apps };
}
+async function iosUsage({ days = 7 } = {}) {
+ days = Number(days);
+ if (!Number.isInteger(days) || days < 1 || days > 35) throw new Error("days must be 1..35");
+ const remote = `cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/ios-usage-report.mjs --days ${days}`;
+ const { stdout } = await pexec("ssh", ["-i", SSH_KEY, "-o", "ConnectTimeout=10", LITH, remote],
+ { timeout: 90_000, maxBuffer: 32 * 1024 * 1024 });
+ return JSON.parse(stdout.slice(stdout.indexOf("{")));
+}
+
// đź“… Daily
// lith folds each finished day of visits, direct downloads and app opens into
@@ -283,6 +292,11 @@ const TOOLS = [
startedAfter: { type: "string", description: "Optional ISO date: only visits arriving after this time, useful for watching the first arrival after a deploy" },
} },
},
+ {
+ name: "ios_usage",
+ description: "AC iOS 1.2+ foreground opens, unique active installs, first-observed installs, returning installs, active seconds, successful loads and canvas engagement. App Store downloads remain separate. Older builds have no coverage; no user/account identities are returned.",
+ inputSchema: { type: "object", properties: { days: { type: "integer", description: "UTC days including today (default 7, max 35)" } } },
+ },
{
name: "visits_report",
description: "First-party page visits across AC web properties (from lith's network-visits collector), folded per site: visits, interacted, engaged (10s+), visible minutes, actions, automated traffic. Page visits, not unique people. Retention is 35 days; collection began 2026-09-23.",
@@ -348,6 +362,7 @@ async function callTool(name, args = {}) {
: name === "direct_downloads" ? await directDownloads(args)
: name === "daily_metrics" ? await dailyMetrics(args)
: name === "app_opens" ? await appOpens(args)
+ : name === "ios_usage" ? await iosUsage(args)
: name === "app_downloads" ? await appDownloads(args)
: (() => { throw new Error(`unknown tool ${name}`); })();
return [{ type: "text", text: JSON.stringify(result, null, 2) }];
--
2.51.2