diff --git a/apple/oskiewar-mac/project.yml b/apple/oskiewar-mac/project.yml index 52abbf27d1..4542c9652d 100644 --- a/apple/oskiewar-mac/project.yml +++ b/apple/oskiewar-mac/project.yml @@ -63,6 +63,7 @@ targets: - path: ../oskiewar/Sources/SdfFigures.swift - path: ../oskiewar/Sources/InkOutlines.swift - path: ../oskiewar/Sources/GlyphAtlas.swift + - path: ../oskiewar/Sources/LaunchPing.swift # NativeGameHost.start() reads these back as "live/oskiewar.js" and # "live/qr.mjs" off Bundle.main.resourceURL, so they need the subfolder @@ -84,10 +85,10 @@ targets: - path: ../../system/public/papers.aesthetic.computer/foundry/fonts/ComicRelief-Regular.ttf buildPhase: resources - # One privacy manifest for both Apple apps: same engine, same (absent) - # collection. If the Swift host ever grows an account or an analytics - # call, this file and the App Store Connect nutrition label move - # together. + # One privacy manifest for both Apple apps: same engine, same + # collection (only LaunchPing's unlinked launch count). If the Swift + # host ever grows an account or another analytics call, this file and + # the App Store Connect nutrition label move together. - path: ../oskiewar/PrivacyInfo.xcprivacy buildPhase: resources diff --git a/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj b/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj index af64299f85..0082f02f7f 100644 --- a/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj +++ b/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0DAD924019F446F88876E94A /* InkOutlines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE2644F54ED6192F647BA1B /* InkOutlines.swift */; }; + 1408C6637C150B968A74F224 /* LaunchPing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE46AB7B13B46F2947EA1BCC /* LaunchPing.swift */; }; 1B43BAF5E41E93E3D9208197 /* product-analytics.mjs in Resources */ = {isa = PBXBuildFile; fileRef = A5CDAFE515D4323680797051 /* product-analytics.mjs */; }; 1BB115B2B2EC50D3FFBD88D4 /* scene3d-webgl.mjs in Resources */ = {isa = PBXBuildFile; fileRef = BF7515E8A95A1944C5424BD3 /* scene3d-webgl.mjs */; }; 21F3367D32F88CB3B2A52859 /* oskiewar-sfx.mjs in Resources */ = {isa = PBXBuildFile; fileRef = 2E725A92F239CBD5114A9A1E /* oskiewar-sfx.mjs */; }; @@ -51,6 +52,7 @@ C43902C050551AD2D6DA0EF9 /* OskiewarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OskiewarApp.swift; sourceTree = ""; }; CEF913B052D36BC3CE48BB1B /* oskiewar-voice.mjs */ = {isa = PBXFileReference; path = "oskiewar-voice.mjs"; sourceTree = ""; }; D5D135855C9371BD15F15377 /* round-room.mjs */ = {isa = PBXFileReference; path = "round-room.mjs"; sourceTree = ""; }; + DE46AB7B13B46F2947EA1BCC /* LaunchPing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchPing.swift; sourceTree = ""; }; F05D439B6814C2DE2C8CCBF7 /* Oskiewar.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Oskiewar.app; sourceTree = BUILT_PRODUCTS_DIR; }; FCECE2875B24DB7356CB0FDC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ @@ -61,6 +63,7 @@ children = ( 59BD48D8076F347B7FF9A774 /* GlyphAtlas.swift */, 3EE2644F54ED6192F647BA1B /* InkOutlines.swift */, + DE46AB7B13B46F2947EA1BCC /* LaunchPing.swift */, 812FA65365747604E58B98E7 /* MetalSceneView.swift */, C43902C050551AD2D6DA0EF9 /* OskiewarApp.swift */, 4E537A6D78CBFE2DD45EEA57 /* SdfFigures.swift */, @@ -224,6 +227,7 @@ files = ( B93964796DBF64A171DD9439 /* GlyphAtlas.swift in Sources */, 0DAD924019F446F88876E94A /* InkOutlines.swift in Sources */, + 1408C6637C150B968A74F224 /* LaunchPing.swift in Sources */, 7D438ADCB6BECAD5D0E6C8F7 /* MetalSceneView.swift in Sources */, C0C46FBB6805F1A1E055FED7 /* OskiewarApp.swift in Sources */, B2694D04A86822B4F7712ABD /* SdfFigures.swift in Sources */, diff --git a/apple/oskiewar/PrivacyInfo.xcprivacy b/apple/oskiewar/PrivacyInfo.xcprivacy index 402f9016e4..2c899e1fe3 100644 --- a/apple/oskiewar/PrivacyInfo.xcprivacy +++ b/apple/oskiewar/PrivacyInfo.xcprivacy @@ -3,10 +3,12 @@ + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyAccessedAPITypes - + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + diff --git a/apple/oskiewar/Sources/LaunchPing.swift b/apple/oskiewar/Sources/LaunchPing.swift new file mode 100644 index 0000000000..e5c44a715b --- /dev/null +++ b/apple/oskiewar/Sources/LaunchPing.swift @@ -0,0 +1,46 @@ +// 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. +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 + } + + private static var platform: String { + #if os(macOS) || targetEnvironment(macCatalyst) + return "mac" + #else + return UIDevice.current.userInterfaceIdiom == .pad ? "ipados" : "ios" + #endif + } +} diff --git a/apple/oskiewar/Sources/OskiewarApp.swift b/apple/oskiewar/Sources/OskiewarApp.swift index 00dd3ec2c4..7ca690ae53 100644 --- a/apple/oskiewar/Sources/OskiewarApp.swift +++ b/apple/oskiewar/Sources/OskiewarApp.swift @@ -4,6 +4,8 @@ import WebKit @main struct OskiewarApp: App { + init() { LaunchPing.send("oskiewar") } + var body: some Scene { WindowGroup { GameControllerSurface().ignoresSafeArea() } } diff --git a/xbox/macos-native/main.swift b/xbox/macos-native/main.swift index 312999208d..6ec2661734 100644 --- a/xbox/macos-native/main.swift +++ b/xbox/macos-native/main.swift @@ -1172,6 +1172,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega private var titleTimer: Timer? func applicationDidFinishLaunching(_ notification: Notification) { + LaunchPing.send("oskiewar") registerComicFont() if let iconURL = Bundle.main.url(forResource: "Oskiewar", withExtension: "icns"), let icon = NSImage(contentsOf: iconURL) { NSApp.applicationIconImage = icon } diff --git a/xbox/tools/build-macos-app.sh b/xbox/tools/build-macos-app.sh index abc17b3d51..3d4b81eb85 100755 --- a/xbox/tools/build-macos-app.sh +++ b/xbox/tools/build-macos-app.sh @@ -14,6 +14,8 @@ mkdir -p "$contents/MacOS" "$contents/Resources/live" # apple/oskiewar — one renderer, compiled into both. main.swift keeps its # top-level code as the entry point, which stays legal as long as it is the # file literally named main.swift. +# -D DEBUG: this is the desk build, so LaunchPing stays quiet; only the +# App Store archive (apple/oskiewar-mac) counts opens. object_dir=$(mktemp -d) trap 'rm -rf "$object_dir"' EXIT clang++ -std=c++17 -O2 -c "$repo_root/xbox/macos-native/PoolDecals.cpp" -o "$object_dir/PoolDecals.o" @@ -23,6 +25,7 @@ swiftc -swift-version 5 -O -import-objc-header "$repo_root/xbox/macos-native/Poo "$repo_root/apple/oskiewar/Sources/SdfFigures.swift" \ "$repo_root/apple/oskiewar/Sources/InkOutlines.swift" \ "$repo_root/apple/oskiewar/Sources/GlyphAtlas.swift" \ + "$repo_root/apple/oskiewar/Sources/LaunchPing.swift" -D DEBUG \ -framework AppKit -framework AVFoundation -framework CoreVideo \ -framework GameController -framework JavaScriptCore \ -framework Metal -framework MetalKit -framework CoreText \