From ecd9b9a74508738b1055144615d085ca30d98cc0 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 19:54:20 -0700 Subject: [PATCH] Whistlegraph 109: trace device actions and fix opaque account identity --- apple/whistlegraph/README.md | 35 ++++ apple/whistlegraph/Resources/Web/engine.mjs | 15 +- .../whistlegraph/Sources/BrainSettings.swift | 5 + .../Sources/DeviceActionLog.swift | 150 ++++++++++++++ apple/whistlegraph/Sources/StoryExport.swift | 3 + .../Sources/VerifiedAccountIdentity.swift | 46 +++++ .../Sources/VersionNarrator.swift | 5 +- .../Sources/WhistlegraphAccount.swift | 30 ++- .../Sources/WhistlegraphApp.swift | 185 +++++++++++++++--- .../Sources/WhistlegraphBraincells.swift | 28 ++- .../Sources/WhistlegraphDebugLog.swift | 103 ++++++++++ .../Sources/WhistlegraphDeleteAccount.swift | 2 + .../Sources/WhistlegraphDrawing.swift | 8 +- .../Sources/WhistlegraphHeader.swift | 6 +- .../Sources/WhistlegraphPrivacy.swift | 4 + .../Sources/WhistlegraphScreen.swift | 20 +- .../Sources/WhistlegraphSource.swift | 4 +- .../whistlegraph/Sources/WhistlegraphTV.swift | 3 + .../Tests/DeviceDiagnosticsCheck.swift | 85 ++++++++ .../whistlegraph/Tests/native-bridge.test.cjs | 9 +- .../whistlegraph/UITests/AIConsentTests.swift | 36 +++- .../Whistlegraph.xcodeproj/project.pbxproj | 18 +- apple/whistlegraph/project.yml | 2 +- 23 files changed, 741 insertions(+), 61 deletions(-) create mode 100644 apple/whistlegraph/Sources/DeviceActionLog.swift create mode 100644 apple/whistlegraph/Sources/VerifiedAccountIdentity.swift create mode 100644 apple/whistlegraph/Sources/WhistlegraphDebugLog.swift create mode 100644 apple/whistlegraph/Tests/DeviceDiagnosticsCheck.swift diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index dc91407fdf..c2c04a44cf 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -310,6 +310,41 @@ node --test Tests/inference-error.test.mjs ``` +## On-device diagnostics and account verification (build 109) + +Account → Debug log (also in Brain) shows recent actions and prepares a JSONL +export for an explicit Share action. Nothing uploads automatically. Four +256 KiB files rotate in Application Support/ActionLog, survive relaunch, use +iOS file protection, and are excluded from backups. Clear log and successful +account deletion remove the trace and its prepared export. + +The trace records native touch begin/end/cancel, screen changes, typed character +counts, drawing stroke counts, Send/Talk/consent, account verification, engine +handoffs, inference stages, credit balances/statuses, StoreKit delivery, source +edits, and story actions. It records no touch coordinates, accessibility labels, +keystrokes, prompts, source, transcripts, audio, account IDs, tokens, URLs, or +error descriptions. The native authentication page is excluded from touch +observation; external wallet/Apple sheets and other apps are outside the trace. +Events, outcomes, controls and stages use closed enums; metadata is numeric. +Model selections record an index into the snapshot's model menu, with the app +build identifying that menu. These logs cannot reconstruct events before 109. + +Native identity now resolves the access token through Auth0 userinfo, matching +the web client and supporting opaque tokens. Concurrent lookups share one +request; refreshed credentials and account changes invalidate cached identity. +First-use consent stays off onboarding. Explicit AI attempts show account-check +progress or a failure message. Send keeps the typed draft until the engine +accepts it; engine rejections return a reason instead of disappearing. + +```sh +xcrun swiftc -j 2 Sources/DeviceActionLog.swift Sources/VerifiedAccountIdentity.swift Tests/DeviceDiagnosticsCheck.swift -o /tmp/whistlegraph-diagnostics-check +/tmp/whistlegraph-diagnostics-check +``` + +`AIConsentTests` uses an isolated opaque-token identity response and no real +inference, purchases, or microphone. It covers first Send/Talk, persistence, +identity lookup failure with draft retention, and local log export/redaction. + ## AI permissions, source and account deletion (build 107) Brain → AI & privacy discloses the text, source/history, drawings, cropped preview diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index 15ad262e26..a64424ddd8 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -63,17 +63,19 @@ let turnHandle='',turnModel='',activeModel='',braincells=null,braincellsError='' function selectedModel(handle=accountHandle){try{return localStorage.getItem('whistlegraph-model-'+handle)||'';}catch{return '';}} function profile(repair=false){return generationProfile(busy?turnHandle:accountHandle,{repair,model:busy?turnModel:selectedModel()});} async function refreshBraincells(){ + benchmark('braincellsRequest'); const currentToken=token,request=++creditsRequest; if(!currentToken){braincells=null;braincellsError='Sign in to view braincells';nativeSnapshot();return;} braincellsError='';nativeSnapshot(); try{ const response=await fetch('https://aesthetic.computer/api/easel-credits',{headers:{Authorization:'Bearer '+currentToken},signal:AbortSignal.timeout(8000)}); + benchmark('braincellsHeaders',{status:response.status}); if(!response.ok)throw Error('Braincells unavailable'); const value=await response.json(); if(![value.remaining,value.used,value.limit,value.purchased].every(v=>Number.isFinite(v)&&v>=0))throw Error('Braincells unavailable'); if(token!==currentToken||request!==creditsRequest)return; - braincells=value;braincellsError=''; - }catch{if(token!==currentToken||request!==creditsRequest)return;braincells=null;braincellsError='Braincells unavailable';} + braincells=value;braincellsError='';benchmark('braincellsLoaded'); + }catch{if(token!==currentToken||request!==creditsRequest)return;braincells=null;braincellsError='Could not load braincells. Check your connection and tap Refresh.';benchmark('braincellsFailed');} nativeSnapshot(); } function inferenceSnapshot(){ @@ -165,6 +167,14 @@ function nativeSnapshot(){ },80); } window.walkiewareNativeCommand=command=>{ + if(['ask','retry'].includes(command.action)) { + if(busy)return {accepted:false,reason:'busy'}; + if(!versions||(command.action==='retry'&&(!ready||!painted)))return {accepted:false,reason:'notReady'}; + if(!accountHandle||accountToken!==token)return {accepted:false,reason:'authentication'}; + if(!aiConsent.allowed)return {accepted:false,reason:'permission'}; + if(command.action==='ask'&&(typeof command.text!=='string'||(!command.text.trim()&&!command.drawing)))return {accepted:false,reason:'emptyInput'}; + if(command.action==='ask'&&Array.from(new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(command.text)).length>96)return {accepted:false,reason:'inputTooLong'}; + } if(command.action==='refreshBraincells')void refreshBraincells(); if(command.action==='setModel'&&!busy&&accountHandle&&modelChoices(accountHandle).some(m=>m.id===command.text)){ try{localStorage.setItem('whistlegraph-model-'+accountHandle,command.text);}catch{return;} @@ -182,6 +192,7 @@ window.walkiewareNativeCommand=command=>{ if(command.action==='retry'&&!busy)void resumeAttempt(true); if(command.action==='stop')$('live-stop').click(); if(command.action==='signIn')post({action:'signIn'}); + return {accepted:true}; }; function updateFeed() { if(!versions)return; diff --git a/apple/whistlegraph/Sources/BrainSettings.swift b/apple/whistlegraph/Sources/BrainSettings.swift index d6f4a0f5c4..626b522906 100644 --- a/apple/whistlegraph/Sources/BrainSettings.swift +++ b/apple/whistlegraph/Sources/BrainSettings.swift @@ -73,6 +73,8 @@ struct BrainSettings: View { NavigationLink { WhistlegraphPrivacySheet(session: session) } label: { Label("AI & privacy", systemImage: "hand.raised") }.accessibilityIdentifier("brain-privacy") + NavigationLink { WhistlegraphDebugLog() } label: { Label("Debug log", systemImage: "list.bullet.rectangle") } + .accessibilityIdentifier("brain-debug-log") } if let inference = session.snapshot.inference { Section { @@ -129,6 +131,9 @@ struct BrainSettings: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } }.presentationDetents([.medium, .large]) + .onAppear { DeviceActionLog.shared.record(.screen, .presented, control: .brain) } + .onDisappear { DeviceActionLog.shared.record(.screen, .dismissed, control: .brain) } + .onChange(of: costUnit) { _, value in DeviceActionLog.shared.record(.setting, .succeeded, control: .costUnit, [.format: CostUnit.allCases.firstIndex(of: value) ?? -1]) } .task { await prices.refresh() #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG diff --git a/apple/whistlegraph/Sources/DeviceActionLog.swift b/apple/whistlegraph/Sources/DeviceActionLog.swift new file mode 100644 index 0000000000..6c089cace8 --- /dev/null +++ b/apple/whistlegraph/Sources/DeviceActionLog.swift @@ -0,0 +1,150 @@ +import Foundation + +/// Local diagnostics only. The closed vocabulary deliberately cannot carry +/// text, account identifiers, URLs, credentials, source code, or audio. +final class DeviceActionLog: @unchecked Sendable { + enum Event: String, Codable { + case launch, lifecycle, touch, screen, setting, command, commandDelivery + case typeEdit, typeSend, talkBegin, talkEnd, talkLatch, talkCancel, speech + case accountToken, accountIdentity, signIn, signOut, consent, consentBridge + case workspace, snapshot, inference, credits, storeCatalog, storePurchase, storeDelivery + case drawing, preview, story, share, deletion, logExport, logClear, source, projection + } + enum Outcome: String, Codable { + case requested, started, ended, ready, succeeded, failed, cancelled, denied + case notSignedIn, notReady, busy, accountChanged, emptyInput, inputTooLong, captureActive + case unavailable, exhausted, declined, pending, alreadyAdded, ignored, presented, dismissed + case active, inactive, background, opening, recording, processing, partial, final, sound + case enabled, disabled, add, undo, clear, committed, painted, invalidated, httpError + case networkError, invalidResponse, providerCredits, userCredits, permission, authentication + case microphonePermission, speechPermission, recognizerUnavailable, noMicrophone, next, previous, paused, resumed + } + enum Control: String, Codable { + case type, talk, chalk, brain, account, pieces, story, tv, privacy, deleteAccount, debugLog + case ask, retry, stop, signIn, newPiece, openPiece, checkout, presentVersion, endPresentation + case setModel, refreshBraincells, density, format, appearance, sounds, costUnit, creation + case cloudSpeech, cloudNarration, requestText, send, cancel, allow, decline, drawingPad + } + enum Metric: String { case characters, version, busy, engineReady, remaining, purchased, used, limit, status, errorCode, strokes, points, enabled, durationMs, accountGeneration, pixelSize, format, touches, selection } + enum Stage: String, Codable { + case requestDispatched, firstModelOutput, firstCheckpoint, generationFinished, generationFailed + case signedIn, generationEntered, guidesReady, inferenceHeaders, inputSocketReady, inputSocketAck + case inputHttpFallback, refinementFailed, localEditDispatched, localEditPainted + case braincellsRequest, braincellsHeaders, braincellsLoaded, braincellsFailed + } + struct Entry: Codable { + let at: Date + let elapsedMs: Int + let session: UUID + let sequence: Int + let build: String + let event: Event + let outcome: Outcome? + let control: Control? + let stage: Stage? + let counts: [String: Int] + } + static let shared = DeviceActionLog() + private let queue = DispatchQueue(label: "computer.aesthetic.whistlegraph.action-log", qos: .utility) + private let directory: URL + private let fileLimit: Int + private let fileCount: Int + private let session = UUID() + private let started = ProcessInfo.processInfo.systemUptime + private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "test" + private var sequence = 0 + private var failure = false + private var prepared = false + + init(directory: URL? = nil, fileLimit: Int = 256 * 1024, fileCount: Int = 4) { + self.directory = directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ActionLog", isDirectory: true) + self.fileLimit = max(1024, fileLimit); self.fileCount = max(1, fileCount) + } + func record(_ event: Event, _ outcome: Outcome? = nil, control: Control? = nil, _ counts: [Metric: Int] = [:], stage: Stage? = nil) { + let at = Date(), elapsedMs = Int((ProcessInfo.processInfo.systemUptime - started) * 1000) + queue.async { [self] in + do { + try prepare() + sequence += 1 + let entry = Entry(at: at, elapsedMs: elapsedMs, session: session, + sequence: sequence, build: build, event: event, outcome: outcome, control: control, stage: stage, + counts: Dictionary(uniqueKeysWithValues: counts.map { ($0.key.rawValue, $0.value) })) + let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601; encoder.outputFormatting = [.sortedKeys] + var data = try encoder.encode(entry); data.append(10) + let current = file(0) + let size = (try? current.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + if size + data.count > fileLimit { try rotate() } + if !FileManager.default.fileExists(atPath: current.path) { try protectedWrite(Data(), to: current) } + let handle = try FileHandle(forWritingTo: current) + defer { try? handle.close() } + try handle.seekToEnd(); try handle.write(contentsOf: data) + } catch { failure = true; prepared = false } // Diagnostics must never stop creation. + } + } + func recordError(_ event: Event, _ error: Error) { + record(event, error is URLError ? .networkError : .failed, [.errorCode: (error as NSError).code]) + } + var hasWriteFailure: Bool { queue.sync { failure } } + func snapshot() throws -> String { try queue.sync { String(decoding: try snapshotData(), as: UTF8.self) } } + func export() throws -> URL { + try queue.sync { + try prepare() + let url = directory.appendingPathComponent("Whistlegraph-debug.jsonl") + try protectedWrite(try snapshotData(), to: url) + return url + } + } + func clear() throws { + try queue.sync { + if FileManager.default.fileExists(atPath: directory.path) { try FileManager.default.removeItem(at: directory) } + failure = false + prepared = false + } + } + private func file(_ index: Int) -> URL { directory.appendingPathComponent("actions-\(index).jsonl") } + private func prepare() throws { + guard !prepared else { return } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var url = directory; var values = URLResourceValues(); values.isExcludedFromBackup = true + try url.setResourceValues(values) + #if os(iOS) + try FileManager.default.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: directory.path) + #endif + prepared = true + } + private func protectedWrite(_ data: Data, to url: URL) throws { + #if os(iOS) + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + #else + try data.write(to: url, options: .atomic) + #endif + } + private func rotate() throws { + let manager = FileManager.default + if manager.fileExists(atPath: file(fileCount - 1).path) { try manager.removeItem(at: file(fileCount - 1)) } + if fileCount > 1 { + for index in stride(from: fileCount - 2, through: 0, by: -1) where manager.fileExists(atPath: file(index).path) { + try manager.moveItem(at: file(index), to: file(index + 1)) + } + } + } + private func snapshotData() throws -> Data { + var result = Data() + for index in (0.. Outcome { + let text = text.lowercased() + if text.contains("microphone permission") { return .microphonePermission } + if text.contains("speech permission") { return .speechPermission } + if text.contains("on-device") && text.contains("unavailable") { return .recognizerUnavailable } + if text.contains("no microphone") { return .noMicrophone } + if text.contains("provider") && (text.contains("credit") || text.contains("balance")) { return .providerCredits } + if (text.contains("braincell") || text.contains("allowance")) && (text.contains("exhaust") || text.contains("ran out") || text.contains("not enough") || text.contains("insufficient")) { return .userCredits } + if text.contains("permission") || text.contains("consent") { return .permission } + if text.contains("sign in") || text.contains("sign-in") || text.contains("unauthorized") { return .authentication } + return .failed + } +} diff --git a/apple/whistlegraph/Sources/StoryExport.swift b/apple/whistlegraph/Sources/StoryExport.swift index 9d8fcc4890..ef6bd50940 100644 --- a/apple/whistlegraph/Sources/StoryExport.swift +++ b/apple/whistlegraph/Sources/StoryExport.swift @@ -116,6 +116,7 @@ struct StoryMovie: Identifiable { let id = UUID(); let url: URL } } } func request() { + DeviceActionLog.shared.record(.share, .started, control: .story) if let readyURL { movie = StoryMovie(url: readyURL); return } requested = true; active = true; busy = true; error = "" assembleIfReady() @@ -131,6 +132,7 @@ struct StoryMovie: Identifiable { let id = UUID(); let url: URL } reset = Task { await earlier?.value; try? await current?.storyTape("cancel") } } func cancel() { + if requested { DeviceActionLog.shared.record(.share, .cancelled, control: .story) } active = false; storyRun = UUID(); assembly?.cancel(); assembly = nil discardCard(); busy = false; requested = false } @@ -144,6 +146,7 @@ struct StoryMovie: Identifiable { let id = UUID(); let url: URL } completion?.resume(); completion = nil } private func fail(_ message: String) { + DeviceActionLog.shared.record(.share, .failed, control: .story) let showError = requested cancel() // Automatic preparation must not interrupt browsing with a modal alert. diff --git a/apple/whistlegraph/Sources/VerifiedAccountIdentity.swift b/apple/whistlegraph/Sources/VerifiedAccountIdentity.swift new file mode 100644 index 0000000000..385d8472be --- /dev/null +++ b/apple/whistlegraph/Sources/VerifiedAccountIdentity.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Access tokens may be opaque. Resolve identity with the same authenticated +/// userinfo endpoint as the web client; never infer it from JWT-shaped text. +@MainActor final class VerifiedAccountIdentity { + enum Failure: Error { case http(Int), invalidResponse, changed } + typealias Fetch = (URLRequest) async throws -> (Data, URLResponse) + private var cached: (token: String, generation: Int, subject: String)? + private var pending: (id: UUID, token: String, generation: Int, task: Task)? + private let fetch: Fetch + init(fetch: @escaping Fetch = { try await URLSession.shared.data(for: $0) }) { self.fetch = fetch } + func invalidate() { cached = nil; pending?.task.cancel(); pending = nil } + func subject(token: String, generation: Int) async throws -> String { + if let cached, cached.token == token, cached.generation == generation { return cached.subject } + if let pending, pending.token == token, pending.generation == generation { + let subject = try await pending.task.value + guard self.pending?.id == pending.id || (cached?.token == token && cached?.generation == generation) else { throw Failure.changed } + return subject + } + invalidate() + let id = UUID(), fetch = fetch + let task = Task { + var request = URLRequest(url: URL(string: "https://hi.aesthetic.computer/userinfo")!) + request.timeoutInterval = 8 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + DeviceActionLog.shared.record(.accountIdentity, .started) + let (data, response) = try await fetch(request) + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { throw Failure.invalidResponse } + DeviceActionLog.shared.record(.accountIdentity, response.statusCode == 200 ? .succeeded : .httpError, [.status: response.statusCode]) + guard response.statusCode == 200 else { throw Failure.http(response.statusCode) } + struct Identity: Decodable { let sub: String } + guard data.count <= 64_000, let value = try? JSONDecoder().decode(Identity.self, from: data), + !value.sub.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, value.sub.count <= 512 else { throw Failure.invalidResponse } + return value.sub + } + pending = (id, token, generation, task) + do { + let subject = try await task.value + guard pending?.id == id else { throw Failure.changed } + cached = (token, generation, subject); pending = nil + return subject + } catch { if pending?.id == id { pending = nil }; throw error } + } +} diff --git a/apple/whistlegraph/Sources/VersionNarrator.swift b/apple/whistlegraph/Sources/VersionNarrator.swift index 307c3fa85d..acee0401a7 100644 --- a/apple/whistlegraph/Sources/VersionNarrator.swift +++ b/apple/whistlegraph/Sources/VersionNarrator.swift @@ -55,16 +55,18 @@ import AVFoundation session.command("checkout", version: version) } func next() { + DeviceActionLog.shared.record(.story, .next) guard isPlaying else { return } onSkip?() if index + 1 < count { index += 1; show() } else { setPaused(true); progress = 1 } } - func previous() { guard isPlaying else { return }; onSkip?(); index = max(0, index - 1); show() } + func previous() { DeviceActionLog.shared.record(.story, .previous); guard isPlaying else { return }; onSkip?(); index = max(0, index - 1); show() } func restart() { guard isPlaying else { return }; onSkip?(); index = 0; isPaused = false; show() } func jump(to target: Int) { guard isPlaying, versions.indices.contains(target) else { return }; onSkip?(); index = target; isPaused = false; show() } func setPaused(_ paused: Bool) { guard isPlaying else { return } + DeviceActionLog.shared.record(.story, paused ? .paused : .resumed) isPaused = paused onPause?(paused) if paused { player?.pause() } @@ -158,6 +160,7 @@ import AVFoundation } } func stop() { + if isPlaying { DeviceActionLog.shared.record(.story, .dismissed) } let wasPlaying = isPlaying clearPlayback(); isPlaying = false; isPaused = false; recordingID = nil if wasPlaying { session?.command("endPresentation") } diff --git a/apple/whistlegraph/Sources/WhistlegraphAccount.swift b/apple/whistlegraph/Sources/WhistlegraphAccount.swift index a5efdadaff..bc54965267 100644 --- a/apple/whistlegraph/Sources/WhistlegraphAccount.swift +++ b/apple/whistlegraph/Sources/WhistlegraphAccount.swift @@ -12,37 +12,40 @@ import UIKit private var completion: ((Result) -> Void)? private var controller: UIViewController? private var exchange: Task? + private let identity = VerifiedAccountIdentity() func token() async throws -> String? { let expectedGeneration = generation var query = key; query[kSecReturnData as String] = true var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data else { return nil } + let data = result as? Data else { DeviceActionLog.shared.record(.accountToken, .notSignedIn); return nil } var tokens = try JSONDecoder().decode(NativeSignIn.Tokens.self, from: data) if tokens.expiresAt.timeIntervalSinceNow < 60 { + DeviceActionLog.shared.record(.accountToken, .started) guard let refresh = tokens.refreshToken else { return nil } tokens = try await NativeSignIn.refresh(refresh) guard generation == expectedGeneration else { return nil } try save(tokens) + DeviceActionLog.shared.record(.accountToken, .succeeded) } return tokens.accessToken } // The server still authenticates every request. This subject only scopes // local preferences to the account instead of a mutable public handle. func subject() async throws -> String? { + let expected = generation guard let token = try await token() else { return nil } - let parts = token.split(separator: ".") - guard parts.count == 3 else { return nil } - var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") - payload += String(repeating: "=", count: (4 - payload.count % 4) % 4) - guard let data = Data(base64Encoded: payload), let claims = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let subject = claims["sub"] as? String, !subject.isEmpty else { return nil } + guard generation == expected else { throw VerifiedAccountIdentity.Failure.changed } + let subject = try await identity.subject(token: token, generation: expected) + guard generation == expected else { throw VerifiedAccountIdentity.Failure.changed } return subject } /// Forgets the stored sign-in. The thread history stays on the device. func signOut() { + DeviceActionLog.shared.record(.signOut, .succeeded) generation += 1 + identity.invalidate() SecItemDelete(key as CFDictionary) } private func save(_ tokens: NativeSignIn.Tokens) throws { @@ -57,7 +60,10 @@ import UIKit guard status == errSecSuccess else { throw NativeSignIn.failure("Could not save sign-in securely.") } } func signIn(from view: WKWebView?, completion: @escaping (Result) -> Void) { - guard controller == nil, let host = view?.window?.rootViewController else { return } + DeviceActionLog.shared.record(.signIn, .requested) + guard controller == nil, let host = view?.window?.rootViewController else { + completion(.failure(NativeSignIn.failure("Sign-in could not open. Close any open sheet and try again."))); return + } do { attempt = try NativeSignIn(); self.completion = completion let config = WKWebViewConfiguration(); config.websiteDataStore = .nonPersistent() @@ -65,6 +71,7 @@ import UIKit let page = UIViewController(); page.view = web; page.title = "Sign in to Aesthetic Computer" page.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)) let navigation = UINavigationController(rootViewController: page); controller = navigation + ActionTouchProbe.TouchObserver.authenticationPresented = true host.present(navigation, animated: true) navigation.presentationController?.delegate = self web.load(URLRequest(url: attempt!.url)) @@ -73,6 +80,11 @@ import UIKit @objc private func cancel() { finish(.failure(NativeSignIn.failure("Sign-in cancelled. Your words are still here."))) } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { cancel() } private func finish(_ result: Result) { + ActionTouchProbe.TouchObserver.authenticationPresented = false + switch result { + case .success: DeviceActionLog.shared.record(.signIn, .succeeded) + case .failure(let error): DeviceActionLog.shared.recordError(.signIn, error) + } exchange?.cancel(); exchange = nil let done = completion; completion = nil; attempt = nil controller?.dismiss(animated: true); controller = nil; done?(result) @@ -87,7 +99,7 @@ import UIKit exchange = Task { do { let tokens = try await NativeSignIn.exchange(body) - try Task.checkCancellation(); generation += 1; try save(tokens) + try Task.checkCancellation(); generation += 1; identity.invalidate(); try save(tokens) finish(.success(tokens.accessToken)) } catch { if !Task.isCancelled { finish(.failure(error)) } } } diff --git a/apple/whistlegraph/Sources/WhistlegraphApp.swift b/apple/whistlegraph/Sources/WhistlegraphApp.swift index c3a0b71141..ba0accd73c 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -10,6 +10,7 @@ struct WhistlegraphApp: App { @Environment(\.scenePhase) private var phase @AppStorage("walkieware-appearance") private var appearance = "system" init() { + DeviceActionLog.shared.record(.launch) if let root = Bundle.main.url(forResource: "Web", withExtension: nil) { for name in ["ComicRelief-Regular.ttf", "ComicRelief-Bold.ttf"] { CTFontManagerRegisterFontsForURL(root.appendingPathComponent(name) as CFURL, .process, nil) } } @@ -38,6 +39,7 @@ struct WhistlegraphApp: App { }.padding(30).foregroundStyle(.primary) } } + .background(ActionTouchProbe().frame(width: 0, height: 0).allowsHitTesting(false)) .task { if !voice.isConsentFixture { await voice.braincells.start(session: voice) } } .onChange(of: voice.snapshot.handle) { _, _ in Task { await voice.syncAIAccount() @@ -49,6 +51,7 @@ struct WhistlegraphApp: App { } .preferredColorScheme(appearance == "light" ? .light : appearance == "dark" ? .dark : nil) .onChange(of: phase) { _, value in + DeviceActionLog.shared.record(.lifecycle, value == .active ? .active : value == .background ? .background : .inactive) if value == .background { voice.cancelHold() } if value == .active && voice.capturePhase == .idle { voice.resumePieceAudio() } if value == .active && !voice.isConsentFixture { @@ -213,6 +216,9 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand @Published var layout = NativeLayout() @Published var snapshot = PieceSnapshot() @Published var showingAIConsent = false + @Published private(set) var verifyingAIAccount = false + @Published var actionError: String? + @Published var typedDraft = "" private var consentRequest: (subject: String, handle: String, generation: Int, code: String, head: Int)? private var afterAIConsent: (() -> Void)? private var acceptedAIConsent = false @@ -232,6 +238,11 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand #if DEBUG @Published var audioTestResult = "Waiting for piece audio" @Published var consentFixtureRequests = 0 + private let consentFixtureIdentity = VerifiedAccountIdentity { request in + let fails = ProcessInfo.processInfo.environment["WALKIE_IDENTITY_FAILURE"] == "1" + return (Data(#"{"sub":"fixture:ai-consent"}"#.utf8), + HTTPURLResponse(url: request.url!, statusCode: fails ? 503 : 200, httpVersion: nil, headerFields: nil)!) + } #endif var isConsentFixture: Bool { #if DEBUG @@ -254,6 +265,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } func setPixelSize(_ size: Int) { + DeviceActionLog.shared.record(.setting, .requested, control: .density, [.pixelSize: size]) guard (1...4).contains(size), !snapshot.busy, capturePhase == .idle else { return } pixelSize = size UserDefaults.standard.set(size, forKey: WhistlegraphPreview.pixelSizeKey) @@ -262,6 +274,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } func setPreviewFormat(_ format: PreviewFormat) { + DeviceActionLog.shared.record(.setting, .requested, control: .format, [.format: PreviewFormat.allCases.firstIndex(of: format) ?? -1]) guard !snapshot.busy, capturePhase == .idle else { return } previewFormat = format UserDefaults.standard.set(format.rawValue, forKey: PreviewFormat.preference) @@ -269,10 +282,13 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand // and the normalized chalk strokes instead of reloading the runtime. } - func command(_ action: String, version: Int? = nil, text: String? = nil, piece: String? = nil) { + func command(_ action: String, version: Int? = nil, text: String? = nil, piece: String? = nil, onAccepted: (() -> Void)? = nil) { guard ["checkout", "newPiece", "openPiece", "stop", "signIn", "ask", "retry", "presentVersion", "endPresentation", "setModel", "refreshBraincells"].contains(action) else { return } + let control = DeviceActionLog.Control(rawValue: action) + DeviceActionLog.shared.record(.command, .requested, control: control, + [.characters: text?.count ?? 0, .version: version ?? snapshot.head, .busy: snapshot.busy ? 1 : 0, .engineReady: engineReady ? 1 : 0]) if ["ask", "retry"].contains(action), !aiConsent.creation { - requestAIConsent { [weak self] in self?.command(action, version: version, text: text, piece: piece) } + requestAIConsent { [weak self] in self?.command(action, version: version, text: text, piece: piece, onAccepted: onAccepted) } return } if action == "newPiece" || action == "openPiece" { @@ -292,44 +308,112 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand var value: [String: Any] = ["action": action] if action == "setModel" { guard !snapshot.busy, capturePhase == .idle, let text else { return } + DeviceActionLog.shared.record(.setting, .requested, control: .setModel, [.selection: snapshot.inference?.models.firstIndex(where: { $0.id == text }) ?? -1]) value["text"] = text } if let version { value["version"] = version } if action == "openPiece", let piece { value["piece"] = piece } if action == "ask" { - guard engineReady, !snapshot.busy, capturePhase == .idle, let text, (!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || drawing.hasInk), text.count <= 96 else { return } + guard canStartAIAction() else { return } + guard let text, (!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || drawing.hasInk) else { + reportActionFailure("Type a few words or add chalk before sending.", reason: .emptyInput); return + } + guard text.count <= 96 else { reportActionFailure("Keep your request to 96 characters or fewer.", reason: .inputTooLong); return } value["text"] = text if let sketch = drawing.payload() { value["drawing"] = sketch } } #if DEBUG if isConsentFixture && ["ask", "retry"].contains(action) { - consentFixtureRequests += 1; return // Exercise the native gate without paid inference. + consentFixtureRequests += 1; onAccepted?(); return // Exercise the native gate without paid inference. } #endif - Task { _ = try? await webView?.callAsyncJavaScript("window.walkiewareNativeCommand?.(command)", arguments: ["command": value], in: nil, contentWorld: .page) } + let commandGeneration = account.generation + Task { + do { + if ["ask", "retry"].contains(action), commandGeneration != account.generation { + reportActionFailure("Your account changed. Try that action again.", reason: .accountChanged); return + } + guard let webView else { throw NativeSignIn.failure("The workspace is still opening. Try again in a moment.") } + let result = try await webView.callAsyncJavaScript("if (!window.walkiewareNativeCommand) throw Error('Workspace not ready'); return window.walkiewareNativeCommand(command);", arguments: ["command": value], in: nil, contentWorld: .page) + if let result = result as? [String: Any], result["accepted"] as? Bool == false { + let reason = (result["reason"] as? String).flatMap(DeviceActionLog.Outcome.init(rawValue:)) ?? .failed + let message: String + switch reason { + case .busy: message = "A piece is still being made. Wait or tap Stop, then send again." + case .authentication: message = "Your account is still connecting. Your draft is here; try sending again in a moment." + case .permission: message = "AI permission has not reached the workspace. Your draft is here; try sending again." + default: message = "The workspace is not ready for that request. Your draft is still here. Try again." + } + reportActionFailure(message, reason: reason); return + } + DeviceActionLog.shared.record(.commandDelivery, .succeeded, control: control) + onAccepted?() + } catch { + DeviceActionLog.shared.recordError(.commandDelivery, error) + if ["ask", "retry", "signIn", "newPiece", "openPiece"].contains(action) { + reportActionFailure("The workspace could not receive that action. Your draft is still here. Try again.", reason: .notReady) + } + } + } + } + private func canStartAIAction() -> Bool { + if !engineReady { reportActionFailure("The workspace is still opening. Try again in a moment.", reason: .notReady); return false } + if snapshot.busy { reportActionFailure("A piece is still being made. Wait for it to finish or tap Stop.", reason: .busy); return false } + if capturePhase != .idle { reportActionFailure("Finish or cancel the current recording first.", reason: .captureActive); return false } + return true + } + func reportActionFailure(_ message: String, reason: DeviceActionLog.Outcome) { + DeviceActionLog.shared.record(.command, reason) + actionError = message } func beginHold() { - guard engineReady, !snapshot.busy, capturePhase == .idle else { return } + DeviceActionLog.shared.record(.talkBegin, .requested) + guard canStartAIAction() else { return } guard !snapshot.handle.isEmpty else { command("signIn"); return } // Permission does not start a microphone after the finger has lifted. guard aiConsent.creation else { requestAIConsent(); return } if isConsentFixture { return } performanceTurn = false captureError = nil; transcript = ""; speechStartedAt = nil; capturePhase = .opening - webView?.evaluateJavaScript("voiceStart()") + webView?.evaluateJavaScript("voiceStart()") { [weak self] _, error in + guard let error else { return } + DeviceActionLog.shared.recordError(.talkBegin, error) + self?.cancel() + self?.reportActionFailure("Recording could not start. Try holding Talk again.", reason: .failed) + } } func latchPerformance() { + DeviceActionLog.shared.record(.talkLatch, .requested) guard capturePhase == .opening || capturePhase == .recording else { return } performanceTurn = true; performanceCapture = true; drawing.enabled = true; capture.latchPerformance() webView?.evaluateJavaScript("window.walkiewareLatchPerformance?.()") } - func endHold() { webView?.evaluateJavaScript("voiceEnd()") } - func syncAIAccount() async { - if isConsentFixture { aiConsent.bind(subject: "fixture:ai-consent", handle: "preview"); return } + func endHold() { DeviceActionLog.shared.record(.talkEnd, .requested); webView?.evaluateJavaScript("voiceEnd()") } + @discardableResult func syncAIAccount(reportFailure: Bool = false) async -> Bool { let expected = account.generation - let subject = try? await account.subject() - guard expected == account.generation else { return } - aiConsent.bind(subject: subject ?? nil, handle: snapshot.handle) + do { + let subject: String? + #if DEBUG + if isConsentFixture { subject = try await consentFixtureIdentity.subject(token: "fixture-opaque-token", generation: expected) } + else { subject = try await account.subject() } + #else + subject = try await account.subject() + #endif + guard expected == account.generation else { return false } + aiConsent.bind(subject: subject, handle: snapshot.handle) + if subject == nil && reportFailure { reportActionFailure("Your sign-in has expired. Sign out from Account, then log in again. Your draft is still here.", reason: .notSignedIn) } + return aiConsent.signedIn + } catch { + guard expected == account.generation else { return false } + DeviceActionLog.shared.recordError(.accountIdentity, error) + aiConsent.bind(subject: nil, handle: snapshot.handle) + if reportFailure { + let expired: Bool + if case VerifiedAccountIdentity.Failure.http(401) = error { expired = true } else { expired = false } + reportActionFailure(expired ? "Your sign-in has expired. Sign out from Account, then log in again. Your draft is still here." : "Could not verify your account. Check your connection and try again. Your draft is still here.", reason: expired ? .authentication : .networkError) + } + return false + } } var canAllowAIConsent: Bool { guard let request = consentRequest else { return false } @@ -337,42 +421,55 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand request.handle == snapshot.handle && request.generation == account.generation } func requestAIConsent(resume: (() -> Void)? = nil) { + DeviceActionLog.shared.record(.consent, .requested) guard !showingAIConsent else { return } if aiConsent.creation { resume?(); return } guard !snapshot.handle.isEmpty else { command("signIn"); return } + guard !verifyingAIAccount else { return } + verifyingAIAccount = true let generation = account.generation, handle = snapshot.handle let code = snapshot.code, head = snapshot.head Task { - await syncAIAccount() + defer { verifyingAIAccount = false } + guard await syncAIAccount(reportFailure: true) else { return } guard generation == account.generation, handle == snapshot.handle, code == snapshot.code, head == snapshot.head, !showingAIConsent, - let subject = aiConsent.subject, aiConsent.signedIn else { return } + let subject = aiConsent.subject, aiConsent.signedIn else { + reportActionFailure("Your account or piece changed. Try that action again.", reason: .accountChanged); return + } if aiConsent.creation { resume?(); return } consentRequest = (subject, handle, generation, code, head) afterAIConsent = resume; acceptedAIConsent = false showingAIConsent = true + DeviceActionLog.shared.record(.consent, .presented) } } func allowAIConsent() { + DeviceActionLog.shared.record(.consent, .succeeded, control: .allow) guard canAllowAIConsent else { declineAIConsent(); return } aiConsent.set(\.creation, true) acceptedAIConsent = true; showingAIConsent = false } func declineAIConsent() { + DeviceActionLog.shared.record(.consent, .declined) acceptedAIConsent = false; showingAIConsent = false } func finishAIConsentPrompt() { + DeviceActionLog.shared.record(.consent, .dismissed) let request = consentRequest, resume = afterAIConsent let accepted = acceptedAIConsent && canAllowAIConsent consentRequest = nil; afterAIConsent = nil; acceptedAIConsent = false guard accepted, let request, let resume else { return } Task { // Await the WebView gate before continuing the original action. - guard let webView else { return } + guard let webView else { reportActionFailure("The workspace is not ready. Try again.", reason: .notReady); return } do { _ = try await webView.callAsyncJavaScript("window.__whistlegraphAIConsent = value; window.walkiewareSetAIConsent?.(value);", arguments: ["value": aiConsent.bridge], in: nil, contentWorld: .page) - } catch { return } + } catch { + DeviceActionLog.shared.recordError(.consentBridge, error) + reportActionFailure("Could not apply AI permission. Your draft is still here. Try again.", reason: .failed); return + } guard aiConsent.creation, request.subject == aiConsent.subject, request.handle == snapshot.handle, request.generation == account.generation, request.code == snapshot.code, request.head == snapshot.head else { return } @@ -418,8 +515,10 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } catch { cleanupError = error } if let name = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: name) } drawing.clear(); drawing.enabled = false + typedDraft = "" snapshot = PieceSnapshot(); pieces = []; previewSource = ""; engineReady = false reloadWorkspace() + try DeviceActionLog.shared.clear() if let cleanupError { throw cleanupError } } /// Drops the Keychain sign-in and tells the engine, which parks its sockets. @@ -429,7 +528,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand aiConsent.bind(subject: nil, handle: "") emitEngine(["kind": "account", "token": ""]) } - func cancelHold() { performanceCapture = false; webView?.evaluateJavaScript("voiceEnd(true)"); cancel() } + func cancelHold() { DeviceActionLog.shared.record(.talkCancel, .requested); performanceCapture = false; webView?.evaluateJavaScript("voiceEnd(true)"); cancel() } @Published var startupFailure: String? private var startupTimeout: Task? @@ -437,6 +536,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand weak var webView: WKWebView? func reloadWorkspace() { + DeviceActionLog.shared.record(.workspace, .opening) workspaceReady = false; engineReady = false; startupFailure = nil startupTimeout?.cancel() webView?.load(URLRequest(url: URL(string: "walkieware://app/index.html?walkie=1")!)) @@ -444,6 +544,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand try? await Task.sleep(for: .seconds(12)) guard !Task.isCancelled, let self, !self.workspaceReady else { return } self.startupFailure = "The workspace did not finish opening. Tap Reload to try again." + DeviceActionLog.shared.record(.workspace, .failed) } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -453,6 +554,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + DeviceActionLog.shared.recordError(.workspace, error) startupFailure = "Could not open the workspace: " + error.localizedDescription print("[walkieware] navigation failed: \((error as NSError).code)") } @@ -460,9 +562,11 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand didFailProvisionalNavigation(webView, navigation: navigation, error: error) } private func didFailProvisionalNavigation(_ view: WKWebView, navigation: WKNavigation!, error: Error) { + DeviceActionLog.shared.recordError(.workspace, error) startupFailure = "Could not open the workspace: " + error.localizedDescription } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + DeviceActionLog.shared.record(.workspace, .failed) cancel(); workspaceReady = false print("[walkieware] web content process terminated") if recoveryCount < 1 { recoveryCount += 1; reloadWorkspace() } @@ -544,6 +648,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand #endif if body["action"] as? String == "storyTape" { storyTapeEvent?(body); return } if body["action"] as? String == "previewReady" { + DeviceActionLog.shared.record(.preview, .ready) #if DEBUG print("[walkieware] preview runtime ready") #endif @@ -554,8 +659,8 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand if let event = body["event"] as? [String: Any], event["requestID"] as? Int == previewRequestID, event["sourceHash"] as? String == VisualCapture.hash(previewSource) { - if event["kind"] as? String == "painted" { paintedPreviewHash = event["sourceHash"] as? String; tv.update(previewSource) } - if event["kind"] as? String == "invalidated" { paintedPreviewHash = nil } + if event["kind"] as? String == "painted" { DeviceActionLog.shared.record(.preview, .painted); paintedPreviewHash = event["sourceHash"] as? String; tv.update(previewSource) } + if event["kind"] as? String == "invalidated" { DeviceActionLog.shared.record(.preview, .invalidated); paintedPreviewHash = nil } } #if DEBUG if let event = body["event"] as? [String: Any], event["kind"] as? String == "painted" { print("[walkieware] checkpoint painted"); AudioBenchmark.mark("firstPainted") @@ -631,6 +736,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } } #endif + traceSnapshot(next) snapshot = next; tv.updateProgress(next); engineReady = true case "voiceIdle": performanceCapture = false @@ -652,8 +758,13 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } #endif case "benchmark": + if let name = body["event"] as? String, let stage = DeviceActionLog.Stage(rawValue: name) { + let status = (body["fields"] as? [String: Any])?["status"] as? Int + DeviceActionLog.shared.record(.inference, nil, status.map { [.status: $0] } ?? [:], stage: stage) + } if let name = body["event"] as? String, ["requestDispatched", "firstModelOutput", "firstCheckpoint", "generationFinished", "generationFailed", "transcriptPainted", "signedIn", "generationEntered", "guidesReady", "firstIncrementalCompile", "inferenceHeaders", "jevDecision", "jevFallback", "jevCacheHit", "jevApplied", "inputSocketReady", "inputSocketAck", "inputHttpFallback", "starterDispatched", "starterPainted", "refinementFailed", "localEditDispatched", "localEditPainted"].contains(name) { AudioBenchmark.mark(name, fields: body["fields"] as? [String: Any] ?? [:]) } case "ready": + DeviceActionLog.shared.record(.workspace, .ready) workspaceReady = true; startupFailure = nil; startupTimeout?.cancel() print("[walkieware] controls ready") if AudioBenchmark.enabled || SequenceBenchmark.enabled { UIApplication.shared.isIdleTimerDisabled = true } @@ -661,16 +772,18 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand case "account": if isConsentFixture { emitEngine(["kind": "account", "token": ""]); return } Task { do { emitEngine(["kind": "account", "token": try await account.token() ?? ""]) } - catch { emitEngine(["kind": "account", "token": ""]) } } + catch { DeviceActionLog.shared.recordError(.accountToken, error); emitEngine(["kind": "account", "token": ""]) } } case "aiConsent": // The network guard can reject background recovery or stale account // work. Only native Send/retry/Talk gestures may present permission. - break + DeviceActionLog.shared.record(.consentBridge, .denied) case "signIn": if isConsentFixture { return } account.signIn(from: webView) { [weak self] result in switch result { - case .success(let token): self?.emitEngine(["kind": "account", "token": token]) + case .success(let token): + self?.aiConsent.bind(subject: nil, handle: "") + self?.emitEngine(["kind": "account", "token": token]) case .failure(let error): self?.emitEngine(["kind": "error", "text": error.localizedDescription]) } } @@ -683,6 +796,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand previewRequestID = body["renderID"] as? Int ?? 0 previewSource = source; renderPreview() case "start": + DeviceActionLog.shared.record(.speech, .opening) capturePhase = .opening; captureError = nil; transcript = "" microphoneLevels = Array(repeating: 0, count: 28) capture.start(id) @@ -690,6 +804,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand case "stop": capture.stop(matching: id) case "cancel": if id == capture.turn { cancel() } case "share": + DeviceActionLog.shared.record(.share, .requested) guard let value = body["data"] as? String, value.count < 10_000_000, value.hasPrefix("data:image/png;base64,"), let data = Data(base64Encoded: String(value.dropFirst(22))), @@ -702,6 +817,27 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } } + private func traceSnapshot(_ next: PieceSnapshot) { + if !engineReady || next.busy != snapshot.busy || next.head != snapshot.head || next.handle != snapshot.handle { + DeviceActionLog.shared.record(.snapshot, next.busy ? .processing : .ready, + [.version: next.head, .busy: next.busy ? 1 : 0, .enabled: next.handle.isEmpty ? 0 : 1]) + } + let failure = next.attempt?.status == "failed" ? next.attempt?.error ?? next.error : next.error + let oldFailure = snapshot.attempt?.status == "failed" ? snapshot.attempt?.error ?? snapshot.error : snapshot.error + if !failure.isEmpty && failure != oldFailure { + DeviceActionLog.shared.record(.inference, DeviceActionLog.failureKind(failure)) + } + let current = next.inference?.braincells, previous = snapshot.inference?.braincells + if let current, current.remaining != previous?.remaining || current.purchased != previous?.purchased || current.used != previous?.used { + func count(_ value: Double) -> Int { value.isFinite ? Int(max(-1e12, min(1e12, value))) : 0 } + DeviceActionLog.shared.record(.credits, current.remaining + current.purchased <= 0 && current.unlimited != true ? .exhausted : .ready, + [.remaining: count(current.remaining), .purchased: count(current.purchased), .used: count(current.used), .limit: count(current.limit)]) + } + if let error = next.inference?.braincellsError, !error.isEmpty, error != snapshot.inference?.braincellsError { + DeviceActionLog.shared.record(.credits, DeviceActionLog.failureKind(error)) + } + } + private func renderPreview() { guard let frame = previewFrame, !previewSource.isEmpty else { return } let source = previewSource @@ -719,6 +855,9 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } private func emit(_ kind: String, text: String = "", id: String? = nil) { + let phase: [String: DeviceActionLog.Outcome] = ["listening": .recording, "partial": .partial, + "processing": .processing, "mixedFinal": .final, "final": .final, "error": .failed, "sound": .sound, "musicalObservation": .sound] + if let outcome = phase[kind] { DeviceActionLog.shared.record(.speech, kind == "error" ? DeviceActionLog.failureKind(text) : outcome, [.characters: text.count]) } if ["processing", "mixedFinal", "final", "error"].contains(kind) { resumePieceAudio() } if ["processing", "mixedFinal", "final", "error"].contains(kind) { performanceCapture = false } switch kind { diff --git a/apple/whistlegraph/Sources/WhistlegraphBraincells.swift b/apple/whistlegraph/Sources/WhistlegraphBraincells.swift index 8b1dd2f1ba..65a8229703 100644 --- a/apple/whistlegraph/Sources/WhistlegraphBraincells.swift +++ b/apple/whistlegraph/Sources/WhistlegraphBraincells.swift @@ -32,12 +32,15 @@ import StoreKit func load() async { guard !loading else { return } + DeviceActionLog.shared.record(.storeCatalog, .started) loading = true; defer { loading = false } do { let products = try await Product.products(for: [StoreCreditDelivery.productID]) product = products.first { $0.id == StoreCreditDelivery.productID && $0.type == .consumable } storeStatus = product == nil ? "Braincell purchases are unavailable in the App Store right now." : "" + DeviceActionLog.shared.record(.storeCatalog, product == nil ? .unavailable : .ready) } catch { + DeviceActionLog.shared.recordError(.storeCatalog, error) product = nil storeStatus = "Could not load App Store purchases. Try again." } @@ -45,6 +48,7 @@ import StoreKit func recover() async { guard !recovering else { return } + DeviceActionLog.shared.record(.storeDelivery, .requested) recovering = true; defer { recovering = false } for await result in StoreKit.Transaction.unfinished { await settle(result) } } @@ -75,6 +79,8 @@ import StoreKit } request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) + DeviceActionLog.shared.record(.storeDelivery, (response as? HTTPURLResponse)?.statusCode == 200 ? .succeeded : .httpError, + [.status: (response as? HTTPURLResponse)?.statusCode ?? 0]) guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] throw NativeSignIn.failure(body?["error"] as? String ?? "AC could not confirm the purchase.") @@ -83,10 +89,15 @@ import StoreKit } func buy() async { - guard !busy, let product else { return } + DeviceActionLog.shared.record(.storePurchase, .requested) + guard !busy, let product else { + notice = busy ? "A purchase is already in progress." : "Braincell purchases are unavailable in the App Store right now." + DeviceActionLog.shared.record(.storePurchase, busy ? .busy : .unavailable); return + } busy = true; notice = ""; defer { busy = false } do { guard let auth = try await credential(), isCurrent(auth) else { + DeviceActionLog.shared.record(.storePurchase, .notSignedIn) notice = "Sign in to AC before buying braincells."; return } struct Account: Decodable { let appAccountToken: UUID } @@ -94,15 +105,16 @@ import StoreKit guard isCurrent(auth) else { notice = "Your account changed. Try again."; return } switch try await product.purchase(options: [.appAccountToken(account.appAccountToken)]) { case .success(let result): await settle(result) - case .userCancelled: break - case .pending: notice = "Purchase pending approval. Braincells will be added after approval." + case .userCancelled: DeviceActionLog.shared.record(.storePurchase, .cancelled) + case .pending: DeviceActionLog.shared.record(.storePurchase, .pending); notice = "Purchase pending approval. Braincells will be added after approval." @unknown default: notice = "The App Store has not completed this purchase." } - } catch { notice = error.localizedDescription } + } catch { DeviceActionLog.shared.recordError(.storePurchase, error); notice = error.localizedDescription } } private func settle(_ result: VerificationResult) async { guard case .verified(let transaction) = result else { + DeviceActionLog.shared.record(.storeDelivery, .denied) notice = "The App Store could not verify this purchase. It remains saved for retry."; return } let receipt = StoreCreditDelivery.Receipt(id: String(transaction.id), productID: transaction.productID, @@ -112,10 +124,10 @@ import StoreKit credential: { try await self.credential() }, isCurrent: { self.isCurrent($0) }, request: { try await self.request($0, $1) }, finish: { await transaction.finish() }) switch outcome { - case .added: notice = "1,000,000 braincells added."; session?.command("refreshBraincells") - case .alreadyAdded: notice = "This purchase is already in your balance."; session?.command("refreshBraincells") - case .deferred(let reason): notice = reason - case .ignored: break + case .added: DeviceActionLog.shared.record(.storeDelivery, .succeeded); notice = "1,000,000 braincells added."; session?.command("refreshBraincells") + case .alreadyAdded: DeviceActionLog.shared.record(.storeDelivery, .alreadyAdded); notice = "This purchase is already in your balance."; session?.command("refreshBraincells") + case .deferred(let reason): DeviceActionLog.shared.record(.storeDelivery, .pending); notice = reason + case .ignored: DeviceActionLog.shared.record(.storeDelivery, .ignored) } } } diff --git a/apple/whistlegraph/Sources/WhistlegraphDebugLog.swift b/apple/whistlegraph/Sources/WhistlegraphDebugLog.swift new file mode 100644 index 0000000000..f3c1e58fd8 --- /dev/null +++ b/apple/whistlegraph/Sources/WhistlegraphDebugLog.swift @@ -0,0 +1,103 @@ +import SwiftUI +import UIKit + +struct WhistlegraphDebugLog: View { + @State private var contents = "" + @State private var failure: String? + @State private var export: URL? + @State private var confirmingClear = false + var body: some View { + List { + Section { + Text("Actions and errors stay on this phone. No words, passwords, tokens, artwork, or audio. Up to 1 MB of recent events is kept; nothing is uploaded automatically.") + .font(.footnote).foregroundStyle(.secondary) + Button("Prepare log to share") { + DeviceActionLog.shared.record(.logExport, .requested) + do { export = try DeviceActionLog.shared.export(); failure = nil } + catch { failure = "Could not prepare the log. Try again." } + }.accessibilityIdentifier("debug-log-export") + if let export { ShareLink("Share log…", item: export) } + Button("Refresh", action: refresh) + Button("Clear log", role: .destructive) { confirmingClear = true } + } + if let failure { Text(failure).foregroundStyle(.red) } + Section("Recent events") { + Text(contents.isEmpty ? "No events yet." : contents) + .font(.system(.caption, design: .monospaced)).textSelection(.enabled) + .accessibilityIdentifier("debug-log-contents") + } + } + .navigationTitle("Debug log") + .task { DeviceActionLog.shared.record(.screen, .presented, control: .debugLog); refresh() } + .onDisappear { DeviceActionLog.shared.record(.screen, .dismissed, control: .debugLog) } + .confirmationDialog("Clear this phone’s debug log?", isPresented: $confirmingClear, titleVisibility: .visible) { + Button("Clear log", role: .destructive) { + do { try DeviceActionLog.shared.clear(); export = nil; refresh() } + catch { failure = "Could not clear the log. Try again." } + } + } + } + private func refresh() { + do { + contents = try DeviceActionLog.shared.snapshot().split(separator: "\n").suffix(120).joined(separator: "\n") + failure = DeviceActionLog.shared.hasWriteFailure ? "The phone could not save some events. Check available storage." : nil + } catch { failure = "Could not read the log. Try again." } + } +} + +/// Observe native-window touches, including taps on disabled controls, without +/// recognizing or consuming a gesture. No coordinates or accessibility labels +/// are retained. Keyboard contents and the authentication window are excluded. +struct ActionTouchProbe: UIViewRepresentable { + func makeUIView(context: Context) -> Probe { Probe() } + func updateUIView(_ view: Probe, context: Context) {} + @MainActor final class Probe: UIView { + private let observer = TouchObserver() + override func didMoveToWindow() { + super.didMoveToWindow() + observer.view?.removeGestureRecognizer(observer) + window?.addGestureRecognizer(observer) + } + } + @MainActor final class TouchObserver: UIGestureRecognizer { + static var authenticationPresented = false + private var started: TimeInterval = 0 + private var control: DeviceActionLog.Control? + private var recording = false + override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + cancelsTouchesInView = false; delaysTouchesBegan = false; delaysTouchesEnded = false + } + convenience init() { self.init(target: nil, action: nil) } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { false } + override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool { false } + override func touchesBegan(_ touches: Set, with event: UIEvent) { + guard !Self.authenticationPresented else { state = .failed; return } + var view = touches.first?.view + control = nil + let controls: [String: DeviceActionLog.Control] = ["type-control": .type, "talk-control": .talk, + "draw-control": .chalk, "drawing-pad": .drawingPad, "drawing-send": .send, + "brain-settings": .brain, "workspace-account": .account, "workspace-settings": .pieces, + "play-versions": .story, "project-tv": .tv, "typed-request": .requestText, + "request-send": .send, "request-cancel": .cancel, "ai-consent-allow": .allow, "ai-consent-not-now": .decline] + while let current = view { + if current is UITextField || current is UITextView { state = .failed; return } + if let identifier = current.accessibilityIdentifier, let known = controls[identifier] { control = known; break } + view = current.superview + } + recording = true; started = ProcessInfo.processInfo.systemUptime + DeviceActionLog.shared.record(.touch, .started, control: control, [.touches: touches.count]) + } + override func touchesEnded(_ touches: Set, with event: UIEvent) { finish(.ended) } + override func touchesCancelled(_ touches: Set, with event: UIEvent) { finish(.cancelled) } + private func finish(_ outcome: DeviceActionLog.Outcome) { + if recording && !Self.authenticationPresented { + DeviceActionLog.shared.record(.touch, outcome, control: control, + [.durationMs: Int((ProcessInfo.processInfo.systemUptime - started) * 1000)]) + } + recording = false; state = .failed + } + override func reset() { super.reset(); recording = false; control = nil } + } +} diff --git a/apple/whistlegraph/Sources/WhistlegraphDeleteAccount.swift b/apple/whistlegraph/Sources/WhistlegraphDeleteAccount.swift index 569abe1263..09a5f4e875 100644 --- a/apple/whistlegraph/Sources/WhistlegraphDeleteAccount.swift +++ b/apple/whistlegraph/Sources/WhistlegraphDeleteAccount.swift @@ -56,10 +56,12 @@ struct WhistlegraphDeleteAccountSheet: View { } } private func load() async { + DeviceActionLog.shared.record(.screen, .presented, control: .deleteAccount) busy = true; notice = ""; defer { busy = false } do { preview = try await client?.load() } catch { notice = error.localizedDescription } } private func confirm() async { + DeviceActionLog.shared.record(.deletion, .requested) busy = true; notice = ""; defer { busy = false } do { schedule = try await client?.confirm() } catch { notice = error.localizedDescription } } diff --git a/apple/whistlegraph/Sources/WhistlegraphDrawing.swift b/apple/whistlegraph/Sources/WhistlegraphDrawing.swift index abb9c36214..55d48242a6 100644 --- a/apple/whistlegraph/Sources/WhistlegraphDrawing.swift +++ b/apple/whistlegraph/Sources/WhistlegraphDrawing.swift @@ -3,7 +3,7 @@ import UIKit @MainActor final class DrawingDraft: ObservableObject { - @Published var enabled = false + @Published var enabled = false { didSet { if enabled != oldValue { DeviceActionLog.shared.record(.drawing, enabled ? .enabled : .disabled) } } } @Published private(set) var strokes: [[[Double]]] = [] @Published private(set) var revision = 0 private(set) var id = UUID().uuidString @@ -58,9 +58,9 @@ final class DrawingDraft: ObservableObject { if let pressure { sample.append((min(1, max(0, pressure)) * 1000).rounded()) } strokes[strokes.count - 1].append(sample); revision += 1; persist() } - func end() { active = false } - func undo() { active = false; if hasInk { strokes.removeLast(); revision += 1; persist() } } - func clear() { active = false; strokes = []; revision += 1; id = UUID().uuidString; startedAt = 0; persist() } + func end() { active = false; DeviceActionLog.shared.record(.drawing, .add, [.strokes: strokes.count, .points: strokes.reduce(0) { $0 + $1.count }]) } + func undo() { DeviceActionLog.shared.record(.drawing, .undo); active = false; if hasInk { strokes.removeLast(); revision += 1; persist() } } + func clear() { DeviceActionLog.shared.record(.drawing, .clear); active = false; strokes = []; revision += 1; id = UUID().uuidString; startedAt = 0; persist() } func consume(id: String, revision: Int) { if self.id == id && self.revision == revision { clear() } } func payload(speechStart: TimeInterval? = nil) -> [String: Any]? { guard hasInk else { return nil } diff --git a/apple/whistlegraph/Sources/WhistlegraphHeader.swift b/apple/whistlegraph/Sources/WhistlegraphHeader.swift index 26fcaaff29..e93b69e309 100644 --- a/apple/whistlegraph/Sources/WhistlegraphHeader.swift +++ b/apple/whistlegraph/Sources/WhistlegraphHeader.swift @@ -119,10 +119,11 @@ struct AccountSheet: View { } Section { Toggle("Interface sounds", isOn: $sounds).accessibilityIdentifier("account-sounds") - .onChange(of: sounds) { _, on in if on { ButtonSounds.play(.tick) } } + .onChange(of: sounds) { _, on in DeviceActionLog.shared.record(.setting, on ? .enabled : .disabled, control: .sounds); if on { ButtonSounds.play(.tick) } } } footer: { Text("Keys and buttons respect silent mode. Haptics stay on.") } if let session { Section { + NavigationLink { WhistlegraphDebugLog() } label: { Label("Debug log", systemImage: "list.bullet.rectangle") }.accessibilityIdentifier("account-debug-log") NavigationLink { WhistlegraphPrivacySheet(session: session) } label: { Label("AI & privacy", systemImage: "hand.raised") } NavigationLink { WhistlegraphDeleteAccountSheet(session: session) } label: { Label("Delete AC account", systemImage: "trash") } .disabled(handle.isEmpty).accessibilityIdentifier("account-delete") @@ -136,6 +137,7 @@ struct AccountSheet: View { } } .navigationTitle(handle.isEmpty ? "Account" : "@" + handle) + .onChange(of: appearance) { _, value in DeviceActionLog.shared.record(.setting, .succeeded, control: .appearance, [.selection: ["system", "light", "dark"].firstIndex(of: value) ?? -1]) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .confirmationDialog("Sign out of @" + handle + "?", isPresented: $confirmingSignOut, titleVisibility: .visible) { @@ -173,6 +175,8 @@ struct IdentityHeader: View { }.buttonStyle(.plain).accessibilityLabel(session.snapshot.code + ", pieces").accessibilityIdentifier("workspace-settings") } } + .onChange(of: showingAccount) { _, open in DeviceActionLog.shared.record(.screen, open ? .presented : .dismissed, control: .account) } + .onChange(of: showingPieces) { _, open in DeviceActionLog.shared.record(.screen, open ? .presented : .dismissed, control: .pieces) } .sheet(isPresented: $showingPieces) { PiecesSheet(pieces: session.pieces, colors: session.snapshot.colors, inference: session.snapshot.inference, diff --git a/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift b/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift index cbfe26df02..9a5b4bce8c 100644 --- a/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift +++ b/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift @@ -19,6 +19,8 @@ import SwiftUI } func set(_ key: WritableKeyPath, _ value: Bool) { guard let subject, signedIn else { return } + DeviceActionLog.shared.record(.setting, value ? .enabled : .disabled, + control: key == \.creation ? .creation : key == \.cloudSpeech ? .cloudSpeech : .cloudNarration) record[keyPath: key] = value; record.updatedAt = Date() record.save(subject: subject); changed() } @@ -118,6 +120,8 @@ struct WhistlegraphPrivacySheet: View { } } .navigationTitle("AI & privacy").navigationBarTitleDisplayMode(.inline) + .onAppear { DeviceActionLog.shared.record(.screen, .presented, control: .privacy) } + .onDisappear { DeviceActionLog.shared.record(.screen, .dismissed, control: .privacy) } .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } } } diff --git a/apple/whistlegraph/Sources/WhistlegraphScreen.swift b/apple/whistlegraph/Sources/WhistlegraphScreen.swift index a79ff28151..434f247aa0 100644 --- a/apple/whistlegraph/Sources/WhistlegraphScreen.swift +++ b/apple/whistlegraph/Sources/WhistlegraphScreen.swift @@ -203,6 +203,8 @@ struct WhistlegraphScreen: View { .background(narrator.isPlaying ? storyBackground : Color.clear) if !narrator.error.isEmpty && !narrator.isPlaying { Text(narrator.error).foregroundStyle(.orange) } if let failure = session.captureError, !narrator.isPlaying { Text(failure).font(.body).foregroundStyle(.orange).frame(maxWidth: .infinity, alignment: .leading) } + if !session.snapshot.error.isEmpty && !narrator.isPlaying { Text(session.snapshot.error).font(.body).foregroundStyle(.orange).accessibilityIdentifier("workspace-error") } + if session.verifyingAIAccount { ProgressView("Checking your account…").accessibilityIdentifier("account-verifying") } if !narrator.isPlaying && (session.snapshot.hasPiece || session.snapshot.hasHistory || session.snapshot.busy || session.snapshot.attempt?.status == "failed") { VersionFeed(snapshot: session.snapshot, foreground: paper, selectionColor: paper, textSize: session.layout.historySize, disabled: session.snapshot.busy || session.capturePhase != .idle, holdSelection: drawing.hasInk, stop: { session.command("stop") }, retry: { session.command("retry") }) { narrator.select($0, session: session) } } else if !narrator.isPlaying { Spacer(minLength: 0) } @@ -212,9 +214,9 @@ struct WhistlegraphScreen: View { .safeAreaInset(edge: .bottom, spacing: 8) { Group { if narrator.isPlaying { EmptyView() } else if showComposer { - InlineRequestComposer(theme: theme, disabled: session.snapshot.busy, hasDrawing: drawing.hasInk, cancel: { ButtonSounds.play(.pop); showComposer = false }) { text in + InlineRequestComposer(theme: theme, disabled: session.snapshot.busy || session.verifyingAIAccount, hasDrawing: drawing.hasInk, text: $session.typedDraft, cancel: { ButtonSounds.play(.pop); session.typedDraft = ""; showComposer = false }) { text in session.requestAIConsent { - ButtonSounds.play(.sent); session.command("ask", text: text); showComposer = false + session.command("ask", text: text) { ButtonSounds.play(.sent); session.typedDraft = ""; showComposer = false } } } } else { @@ -248,7 +250,12 @@ struct WhistlegraphScreen: View { Button("OK") { exporter.error = "" } } message: { Text(exporter.error) } .statusBarHidden(narrator.isPlaying) + .alert("Could not continue", isPresented: Binding(get: { session.actionError != nil }, set: { if !$0 { session.actionError = nil } })) { + Button("OK") { session.actionError = nil } + } message: { Text(session.actionError ?? "") } .sheet(isPresented: $showTV) { WhistlegraphTVSheet(tv: session.tv) } + .onChange(of: showComposer) { _, open in DeviceActionLog.shared.record(.screen, open ? .presented : .dismissed, control: .type) } + .onChange(of: showTV) { _, open in DeviceActionLog.shared.record(.screen, open ? .presented : .dismissed, control: .tv) } .onChange(of: narrator.isPlaying) { _, playing in if !playing { exporter.cancel() } } .onChange(of: session.localDataRevision) { _, _ in exporter.cancel(); narrator.stop(); showComposer = false } .onChange(of: session.narratedFrame) { _, _ in narrator.painted(session.narratedVersion) } @@ -261,6 +268,7 @@ struct WhistlegraphScreen: View { .animation(reduceMotion ? nil : .easeInOut(duration: 0.3), value: themePhase) } private func openStory() { + DeviceActionLog.shared.record(.story, .presented) narrator.onNarration = { row, audio in await exporter.startCard(row, audio: audio) } narrator.onCardComplete = { await exporter.finishCard() } narrator.onSkip = { exporter.skipCard() } @@ -270,6 +278,7 @@ struct WhistlegraphScreen: View { exporter.prepare(session: session, rows: narrator.branch) } private func exportStory() { + DeviceActionLog.shared.record(.share, .requested, control: .story) if exporter.readyURL != nil { narrator.setPaused(true); exporter.request(); return } let restart = exporter.needsRestart(before: narrator.index) exporter.request() @@ -518,13 +527,14 @@ struct InlineRequestComposer: View { let theme: WhistlegraphTheme var disabled: Bool var hasDrawing = false + @Binding var text: String let cancel: () -> Void let send: (String) -> Void @FocusState private var focused: Bool - @State private var text = "" @StateObject private var keySounds = PromptKeySounds() private var prompt: String { text.trimmingCharacters(in: .whitespacesAndNewlines) } private func submit() { + DeviceActionLog.shared.record(.typeSend, .requested, [.characters: prompt.count]) guard !disabled, !prompt.isEmpty || hasDrawing else { return } send(prompt) } @@ -535,6 +545,7 @@ struct InlineRequestComposer: View { let previous = singleLine(old), next = singleLine(value) if next != value { text = next } guard next != previous else { return } + DeviceActionLog.shared.record(.typeEdit, nil, [.characters: next.count]) // One click per edit, including delete/paste; never a burst for pasted text // or a second click when the length/newline correction updates the field. let inserted = next.difference(from: previous).compactMap { change -> Character? in @@ -552,11 +563,12 @@ struct InlineRequestComposer: View { .onChange(of: text) { old, value in edited(from: old, to: value) } .accessibilityLabel("Request, up to 96 characters").accessibilityIdentifier("typed-request") HStack { - Button("Cancel") { cancel() } + Button("Cancel") { DeviceActionLog.shared.record(.typeSend, .cancelled); cancel() }.accessibilityIdentifier("request-cancel") Spacer() Text("\(text.count) / 96").accessibilityIdentifier("request-count").monospacedDigit().foregroundStyle(.secondary) Spacer() Button("Send", action: submit) + .accessibilityIdentifier("request-send") .disabled(disabled || (prompt.isEmpty && !hasDrawing)) }.font(.title3) }.padding(14) diff --git a/apple/whistlegraph/Sources/WhistlegraphSource.swift b/apple/whistlegraph/Sources/WhistlegraphSource.swift index e4c18b2cea..07ffce97f5 100644 --- a/apple/whistlegraph/Sources/WhistlegraphSource.swift +++ b/apple/whistlegraph/Sources/WhistlegraphSource.swift @@ -13,10 +13,12 @@ struct WhistlegraphSourceDocument: Decodable { extension WhistlegraphSession { func sourceDocument() async throws -> WhistlegraphSourceDocument { - try await sourceOperation("read", arguments: [:]) + DeviceActionLog.shared.record(.source, .requested) + return try await sourceOperation("read", arguments: [:]) } func applySource(_ source: String, to document: WhistlegraphSourceDocument) async throws -> WhistlegraphSourceDocument { + DeviceActionLog.shared.record(.source, .requested, [.characters: source.count, .version: document.version]) guard !snapshot.busy, capturePhase == .idle else { throw sourceFailure("Finish the current request or recording before editing source.") } diff --git a/apple/whistlegraph/Sources/WhistlegraphTV.swift b/apple/whistlegraph/Sources/WhistlegraphTV.swift index 25bf4b9a03..b0ba2bf2bb 100644 --- a/apple/whistlegraph/Sources/WhistlegraphTV.swift +++ b/apple/whistlegraph/Sources/WhistlegraphTV.swift @@ -49,6 +49,7 @@ struct TVDevice: Identifiable, Equatable { override init() { super.init(); browser.delegate = self } func discover() { + DeviceActionLog.shared.record(.projection, .started) guard !searching else { return } searching = true browser.searchForServices(ofType: "_http._tcp.", inDomain: "local.") @@ -81,6 +82,7 @@ struct TVDevice: Identifiable, Equatable { return try JSONDecoder().decode(DeviceStatus.self, from: data) } func connect(_ device: TVDevice) { + DeviceActionLog.shared.record(.projection, .requested) sending?.cancel(); sending = nil; connection = UUID(); sentSource = "" selected = device; connected = false; previousPiece = ""; status = "Connecting…" let expected = connection @@ -136,6 +138,7 @@ struct TVDevice: Identifiable, Equatable { guard (reply as? HTTPURLResponse)?.statusCode == 200, bytes == Data(source.utf8) else { throw URLError(.cannotDecodeContentData) } } func disconnect() { + DeviceActionLog.shared.record(.projection, .cancelled) progressTask?.cancel(); progressTask = nil let device = selected, previous = previousPiece connection = UUID(); sending?.cancel(); sending = nil; selected = nil; connected = false; sentSource = ""; status = "" diff --git a/apple/whistlegraph/Tests/DeviceDiagnosticsCheck.swift b/apple/whistlegraph/Tests/DeviceDiagnosticsCheck.swift new file mode 100644 index 0000000000..1b852e165e --- /dev/null +++ b/apple/whistlegraph/Tests/DeviceDiagnosticsCheck.swift @@ -0,0 +1,85 @@ +import Foundation + +@main struct DeviceDiagnosticsCheck { + @MainActor static func main() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("whistlegraph-diagnostics-test-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let log = DeviceActionLog(directory: root, fileLimit: 2048, fileCount: 3) + DispatchQueue.concurrentPerform(iterations: 200) { n in log.record(.typeSend, .requested, [.characters: n]) } + let snapshot = try log.snapshot() + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + let entries = try snapshot.split(separator: "\n").map { try decoder.decode(DeviceActionLog.Entry.self, from: Data($0.utf8)) } + precondition(!entries.isEmpty && entries.count < 200, "Old entries rotate out") + precondition(zip(entries, entries.dropFirst()).allSatisfy { $0.sequence + 1 == $1.sequence }, "Concurrent writes stay complete and ordered") + let files = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: [.fileSizeKey]) + precondition(files.count <= 3) + for file in files { let size = try file.resourceValues(forKeys: [.fileSizeKey]).fileSize!; precondition(size <= 2048) } + let excluded = try root.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup + precondition(excluded == true) + let exported = try log.export() + let exportedText = try String(contentsOf: exported, encoding: .utf8) + precondition(exportedText == snapshot) + let reopened = DeviceActionLog(directory: root, fileLimit: 2048, fileCount: 3) + let restored = try reopened.snapshot() + precondition(restored == snapshot, "Survives a process restart") + let secret = "PRIVATE_TOKEN_AND_PROMPT" + log.recordError(.accountIdentity, NSError(domain: secret, code: 401, userInfo: [NSLocalizedDescriptionKey: secret])) + let redacted = try log.snapshot() + precondition(!redacted.contains(secret), "Errors cannot leak their domain or description") + try log.clear() + let cleared = try log.snapshot() + precondition(!FileManager.default.fileExists(atPath: exported.path) && cleared.isEmpty) + let blocker = root.appendingPathExtension("file") + try Data().write(to: blocker); defer { try? FileManager.default.removeItem(at: blocker) } + let broken = DeviceActionLog(directory: blocker.appendingPathComponent("cannot-create")) + broken.record(.launch) + precondition(broken.hasWriteFailure, "A full/unwritable store reports failure without crashing") + + var calls = 0 + let identity = VerifiedAccountIdentity { request in + calls += 1 + precondition(request.url?.absoluteString == "https://hi.aesthetic.computer/userinfo") + precondition(request.value(forHTTPHeaderField: "Authorization") == "Bearer opaque-token") + try await Task.sleep(for: .milliseconds(20)) + return (Data(#"{"sub":"account-a"}"#.utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + async let a = identity.subject(token: "opaque-token", generation: 1) + async let b = identity.subject(token: "opaque-token", generation: 1) + let both = try await [a, b] + precondition(both == ["account-a", "account-a"] && calls == 1, "Opaque tokens work and concurrent requests share one lookup") + let cached = try await identity.subject(token: "opaque-token", generation: 1) + precondition(cached == "account-a" && calls == 1) + identity.invalidate() + _ = try await identity.subject(token: "opaque-token", generation: 2) + precondition(calls == 2, "Account changes invalidate identity") + var latest = "account-b" + let switched = VerifiedAccountIdentity { request in + (Data("{\"sub\":\"\(latest)\"}".utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + let first = try await switched.subject(token: "looks.like.jwt", generation: 1) + latest = "account-c" + let second = try await switched.subject(token: "fresh-token", generation: 1) + precondition(first == "account-b" && second == "account-c", "JWT-shaped tokens are verified too; refreshed credentials invalidate the cache") + for (status, body) in [(401, #"{"sub":"wrong"}"#), (503, "unavailable"), (200, "{}"), (200, #"{"sub":" "}"#), (200, "not json")] { + let failure = VerifiedAccountIdentity { request in (Data(body.utf8), HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!) } + do { _ = try await failure.subject(token: "opaque", generation: 1); preconditionFailure("Invalid identity must fail") } + catch { } + } + let timeout = VerifiedAccountIdentity { _ in throw URLError(.timedOut) } + do { _ = try await timeout.subject(token: "opaque", generation: 1); preconditionFailure("Timeout must fail") } + catch { precondition(error is URLError) } + var started = false + let stale = VerifiedAccountIdentity { request in + started = true + // Deliberately ignores cancellation like a late network response. + try? await Task.sleep(for: .milliseconds(40)) + return (Data(#"{"sub":"old-account"}"#.utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + let old = Task { try await stale.subject(token: "old", generation: 1) } + while !started { await Task.yield() } + stale.invalidate() + do { _ = try await old.value; preconditionFailure("Stale account lookup must not succeed") } + catch { } + print("DeviceDiagnosticsCheck passed: rotation, concurrency, persistence, export, clear, redaction, write failure; opaque identity, cache, refresh, HTTP/malformed/timeout failures and account-switch fencing.") + } +} diff --git a/apple/whistlegraph/Tests/native-bridge.test.cjs b/apple/whistlegraph/Tests/native-bridge.test.cjs index c5b98c0d14..42f44ebe53 100644 --- a/apple/whistlegraph/Tests/native-bridge.test.cjs +++ b/apple/whistlegraph/Tests/native-bridge.test.cjs @@ -262,8 +262,14 @@ const server = http.createServer(async (req, res) => { await page.waitForFunction(()=>__nativeMessages.some(m=>m.action==='snapshot')); assert.equal(await page.$eval('.top',e=>getComputedStyle(e).display),'none','Swift owns visible chrome'); await page.evaluate(()=>walkiewareEngineEvent({kind:'account',token:'fixture-only'})); - await page.evaluate(()=>walkiewareNativeCommand({action:'ask',text:'x'.repeat(97)})); + await page.waitForFunction(()=>__nativeMessages.some(m=>m.action==='snapshot'&&m.snapshot.handle==='fixture')); + assert.deepEqual(await page.evaluate(()=>walkiewareNativeCommand({action:'ask',text:'x'.repeat(97)})),{accepted:false,reason:'inputTooLong'}); await pause(100);assert.equal(requests,0,'native typed bridge rejects requests longer than 96 characters'); + assert.deepEqual(await page.evaluate(()=>walkiewareNativeCommand({action:'ask',text:''})),{accepted:false,reason:'emptyInput'}); + await page.evaluate(()=>walkiewareSetAIConsent({handle:'fixture',creation:false})); + assert.deepEqual(await page.evaluate(()=>walkiewareNativeCommand({action:'ask',text:'Keep my draft'})),{accepted:false,reason:'permission'}); + assert.equal(requests,0,'a denied command returns its reason before dispatch'); + await page.evaluate(()=>walkiewareSetAIConsent({handle:'fixture',creation:true})); await page.evaluate(()=>walkiewareAskSound({transcript:'A night garden',words:[{text:'garden',atMs:0,durationMs:500}],sound:{schema:'walkieware-sound/v1',durationMs:1000,audibleMs:500,frames:[{atMs:0,rms:.2,pitchHz:1777}],onsetsMs:[],recordingID:'7f936621-9d84-42aa-923a-85e258f8b0a0'}})); await page.waitForFunction(()=>__nativeMessages.some(m=>m.action==='snapshot'&&m.snapshot.head===1&&!m.snapshot.busy)); const sent=JSON.stringify(inferenceBodies[0]);assert.ok(sent.includes('A night garden'));assert.ok(!sent.includes('7f936621-9d84-42aa-923a-85e258f8b0a0')&&!sent.includes('INPUT DATA:'),'ordinary spoken request excludes recording context at provider boundary'); @@ -273,6 +279,7 @@ const server = http.createServer(async (req, res) => { assert.ok(snapshots.every(s=>(s.output?.length||0)<=6000),'live output stays bounded'); assert.ok(snapshots.filter(s=>s.versions).length<=3,'history only sent when changed'); assert.ok(snapshots.every(s=>!('source' in s)&&!('token' in s)),'snapshots contain presentation only'); + assert.ok(await page.evaluate(()=>__nativeMessages.some(m=>m.action==='benchmark'&&m.event==='braincellsHeaders'&&m.fields.status===200)),'credit response status reaches local diagnostics'); await page.evaluate(()=>walkiewareNativeCommand({action:'checkout',version:0})); await page.waitForFunction(()=>{const s=__nativeMessages.filter(m=>m.action==='snapshot').at(-1)?.snapshot;return s?.head===0&&!s.hasPreview;}); assert.equal(await page.evaluate(()=>JSON.parse(localStorage.getItem('walkieware-source-versions')).versions.length),2); diff --git a/apple/whistlegraph/UITests/AIConsentTests.swift b/apple/whistlegraph/UITests/AIConsentTests.swift index 7543210804..484d80787d 100644 --- a/apple/whistlegraph/UITests/AIConsentTests.swift +++ b/apple/whistlegraph/UITests/AIConsentTests.swift @@ -4,10 +4,11 @@ import XCTest // recording or model request is used. The production native consent flow runs. final class AIConsentTests: XCTestCase { override func setUp() { continueAfterFailure = false } - private func launch() -> XCUIApplication { + private func launch(identityFailure: Bool = false) -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["WALKIE_NATIVE_SCREEN_FIXTURE"] = "consent" app.launchEnvironment["WALKIE_RESET_AI_CONSENT"] = "1" + app.launchEnvironment["WALKIE_IDENTITY_FAILURE"] = identityFailure ? "1" : "0" app.launch() let account = app.buttons.matching(identifier: "workspace-account") .matching(NSPredicate(format: "label == %@", "@preview, account")).firstMatch @@ -22,7 +23,7 @@ final class AIConsentTests: XCTestCase { XCTAssertTrue(field.waitForExistence(timeout: 10)) field.tap(); field.typeText(words) XCTAssertFalse(app.buttons["ai-consent-allow"].exists, "Typing stays local") - app.buttons["Send"].tap() + app.buttons["request-send"].tap() } private func count(_ number: Int, in app: XCUIApplication) { let reached = expectation(for: NSPredicate(format: "label == %@", "Requests: \(number)"), @@ -43,7 +44,7 @@ final class AIConsentTests: XCTestCase { XCTAssertTrue(field.waitForExistence(timeout: 10)) XCTAssertEqual(field.value as? String, "Make a dancing tree") count(0, in: app) - app.buttons["Send"].tap() + app.buttons["request-send"].tap() XCTAssertTrue(allow.waitForExistence(timeout: 10)); allow.tap() count(1, in: app) XCTAssertFalse(field.exists, "Allow continues the original typed request") @@ -67,4 +68,33 @@ final class AIConsentTests: XCTestCase { XCTAssertFalse(app.staticTexts["Listening…"].exists) count(0, in: app) } + func testIdentityFailureExplainsBlockAndKeepsDraft() { + let app = launch(identityFailure: true) + type("Keep this draft", in: app) + let alert = app.alerts["Could not continue"] + XCTAssertTrue(alert.waitForExistence(timeout: 10)) + XCTAssertTrue(alert.staticTexts["Could not verify your account. Check your connection and try again. Your draft is still here."].exists) + alert.buttons["OK"].tap() + XCTAssertEqual(app.textFields["typed-request"].value as? String, "Keep this draft") + XCTAssertFalse(app.buttons["ai-consent-allow"].exists) + count(0, in: app) + } + func testDeviceLogShowsActionsAndExportsWithoutPromptText() { + let app = launch() + type("Secret test drawing", in: app) + XCTAssertTrue(app.buttons["ai-consent-not-now"].waitForExistence(timeout: 10)) + app.buttons["ai-consent-not-now"].tap() + app.buttons["request-cancel"].tap() + app.buttons["workspace-account"].tap() + let log = app.buttons["account-debug-log"] + XCTAssertTrue(log.waitForExistence(timeout: 10)); log.tap() + let contents = app.staticTexts["debug-log-contents"] + XCTAssertTrue(contents.waitForExistence(timeout: 10)) + XCTAssertTrue(contents.label.contains("typeSend")) + XCTAssertTrue(contents.label.contains("accountIdentity")) + XCTAssertTrue(contents.label.contains("touch"), "The passive observer sees taps without blocking them") + XCTAssertFalse(contents.label.contains("Secret test drawing")) + app.buttons["debug-log-export"].tap() + XCTAssertTrue(app.buttons["Share log…"].waitForExistence(timeout: 10)) + } } diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index 82c34ff51a..e0490abbbd 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 561DCBD95EB8AECF60476DE7 /* WhistlegraphTV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DACE2604B34CCB5EB3EE8AC /* WhistlegraphTV.swift */; }; 5646BD41AA620CC47BF89361 /* Web in Resources */ = {isa = PBXBuildFile; fileRef = 4BA75145769FC4401A11BBE8 /* Web */; }; 5D29324F2AF09B53070D7644 /* LiveTranscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7848DB9FED6B955BC193F650 /* LiveTranscription.swift */; }; + 6037259BAADEA55B4760A19A /* DeviceActionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 367C82326F5862EB85ED3260 /* DeviceActionLog.swift */; }; 603B85FC4E28D17FF1E5F457 /* StoryCardStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477B640525BC627AFB40D7A7 /* StoryCardStyle.swift */; }; 6122FB433EC952206856DEBD /* WhistlegraphPrivacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AAFA5A6A0627C23CA67DA4 /* WhistlegraphPrivacy.swift */; }; 620B74996F82DC86FA07B6DB /* CodeSyntax.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2AB1140648295BB7905317 /* CodeSyntax.swift */; }; @@ -62,6 +63,7 @@ AFDE98CF6460438CE2205674 /* WhistlegraphAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060870DF76C6FC078A48A276 /* WhistlegraphAccount.swift */; }; B35A82E6CD62E5D1D9460EE3 /* NativeScreenFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B20A9A8612C57068C67239A /* NativeScreenFixture.swift */; }; C4390906B03E8E8666BC54A3 /* SequenceBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55FB4AA387E716F9FEE0F83 /* SequenceBenchmark.swift */; }; + D11B40EF1D4055DB59CC565D /* VerifiedAccountIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8056005299D13BFB52B7D3E4 /* VerifiedAccountIdentity.swift */; }; D3D16A0333764D0E5C3B620D /* SpeechCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7A25F803FAC121BFECC1AD7 /* SpeechCapture.swift */; }; D8E8FFEA41DF3B0073DC5373 /* TalkHoldInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4728CB634CB3E4AED4F4D353 /* TalkHoldInput.swift */; }; D9E54D2F66B9FC35483A8F45 /* PlaybackCaption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3B156A3435E646EE0E6EEA /* PlaybackCaption.swift */; }; @@ -69,6 +71,7 @@ E5B597B1518B1DB4512EBF31 /* TezosBraincells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 458C9CB2E8C0FD09BF96501D /* TezosBraincells.swift */; }; E81F2B275AC7E65EC667E67F /* RecordedTranscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5E6AF3AF51BAA89BAC231E /* RecordedTranscription.swift */; }; E9F24EB7F859B4C869009F26 /* PaymentBuild.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED23B0C39652687E9ECFFAF /* PaymentBuild.swift */; }; + F160CBA0E689C1B29B1C38FB /* WhistlegraphDebugLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15699BE38BA8C0B44DDABDD3 /* WhistlegraphDebugLog.swift */; }; FAC2911EFFCA887527D2415F /* WhistlegraphDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D9A8C801D3B9D1192D1143 /* WhistlegraphDrawing.swift */; }; /* End PBXBuildFile section */ @@ -88,6 +91,7 @@ 10BFA0F39F2F514E799291F6 /* WhistlegraphBraincells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphBraincells.swift; sourceTree = ""; }; 10E2AAA0A3F2BA1A5AEBED58 /* CodeTicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeTicker.swift; sourceTree = ""; }; 147EAC245AD250B9B652F903 /* WorkspaceCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCoordinator.swift; sourceTree = ""; }; + 15699BE38BA8C0B44DDABDD3 /* WhistlegraphDebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphDebugLog.swift; sourceTree = ""; }; 19C05FBB23D515547035D490 /* WhistlegraphMintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphMintTests.swift; sourceTree = ""; }; 1BC2C1DC5E036F689EA59561 /* WhistlegraphHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphHeader.swift; sourceTree = ""; }; 1CC14C186FE1CB5B19AD1D72 /* AudioBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBenchmark.swift; sourceTree = ""; }; @@ -99,6 +103,7 @@ 30AAFA5A6A0627C23CA67DA4 /* WhistlegraphPrivacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphPrivacy.swift; sourceTree = ""; }; 31AAC74C07F21688CEFF544F /* PieceAudio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PieceAudio.swift; sourceTree = ""; }; 35643029A3E82EEDEAC8DED6 /* InputButtonLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputButtonLabels.swift; sourceTree = ""; }; + 367C82326F5862EB85ED3260 /* DeviceActionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceActionLog.swift; sourceTree = ""; }; 3F98F7D3CE551FC6458D1F37 /* HeaderSheetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSheetsTests.swift; sourceTree = ""; }; 458C9CB2E8C0FD09BF96501D /* TezosBraincells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TezosBraincells.swift; sourceTree = ""; }; 4728CB634CB3E4AED4F4D353 /* TalkHoldInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TalkHoldInput.swift; sourceTree = ""; }; @@ -120,6 +125,7 @@ 6FB1D789A7001E472597133B /* AIConsentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConsentTests.swift; sourceTree = ""; }; 725C2A9ACD0323F49EFFDFC1 /* AccountDeletionClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionClient.swift; sourceTree = ""; }; 7848DB9FED6B955BC193F650 /* LiveTranscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTranscription.swift; sourceTree = ""; }; + 8056005299D13BFB52B7D3E4 /* VerifiedAccountIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifiedAccountIdentity.swift; sourceTree = ""; }; 85CE7F71D92E8328A7BDA44D /* PreviewFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewFormatTests.swift; sourceTree = ""; }; 88B494AD8CD86FCC5DE0E4CC /* StoryExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryExport.swift; sourceTree = ""; }; 8C2AB1140648295BB7905317 /* CodeSyntax.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeSyntax.swift; sourceTree = ""; }; @@ -184,6 +190,7 @@ 8C2AB1140648295BB7905317 /* CodeSyntax.swift */, 10E2AAA0A3F2BA1A5AEBED58 /* CodeTicker.swift */, 919CF3F0659F42F321B22145 /* CostDisplay.swift */, + 367C82326F5862EB85ED3260 /* DeviceActionLog.swift */, 35643029A3E82EEDEAC8DED6 /* InputButtonLabels.swift */, 7848DB9FED6B955BC193F650 /* LiveTranscription.swift */, 5D233DBB6F87184A91C13D41 /* MusicalInput.swift */, @@ -212,11 +219,13 @@ 4728CB634CB3E4AED4F4D353 /* TalkHoldInput.swift */, 458C9CB2E8C0FD09BF96501D /* TezosBraincells.swift */, 8F9B92C3457F07429C8C2600 /* UtteranceRecording.swift */, + 8056005299D13BFB52B7D3E4 /* VerifiedAccountIdentity.swift */, 6D137824491A01D4C19417B8 /* VersionNarrator.swift */, 5DF0A171A5771A7A1FD56852 /* VisualCapture.swift */, 060870DF76C6FC078A48A276 /* WhistlegraphAccount.swift */, BA5F188938A81E704BB7F9F9 /* WhistlegraphApp.swift */, 10BFA0F39F2F514E799291F6 /* WhistlegraphBraincells.swift */, + 15699BE38BA8C0B44DDABDD3 /* WhistlegraphDebugLog.swift */, C534DE3AC6E813F8BDFE5244 /* WhistlegraphDeleteAccount.swift */, 99D9A8C801D3B9D1192D1143 /* WhistlegraphDrawing.swift */, 1BC2C1DC5E036F689EA59561 /* WhistlegraphHeader.swift */, @@ -392,6 +401,7 @@ 620B74996F82DC86FA07B6DB /* CodeSyntax.swift in Sources */, 9DBA1AF2AD7E3EDF69C8132A /* CodeTicker.swift in Sources */, 86581AE590F8A301268220CA /* CostDisplay.swift in Sources */, + 6037259BAADEA55B4760A19A /* DeviceActionLog.swift in Sources */, 123228FADA6696AB354306C7 /* InputButtonLabels.swift in Sources */, 5D29324F2AF09B53070D7644 /* LiveTranscription.swift in Sources */, 14FFBE48767E2576693FB7B3 /* MusicalInput.swift in Sources */, @@ -421,11 +431,13 @@ D8E8FFEA41DF3B0073DC5373 /* TalkHoldInput.swift in Sources */, E5B597B1518B1DB4512EBF31 /* TezosBraincells.swift in Sources */, 49D6E6ED3128002039D4F931 /* UtteranceRecording.swift in Sources */, + D11B40EF1D4055DB59CC565D /* VerifiedAccountIdentity.swift in Sources */, 8A9F944BA95C971D70ADDF6B /* VersionNarrator.swift in Sources */, 749AC25AE02AAE4BCDA5683A /* VisualCapture.swift in Sources */, AFDE98CF6460438CE2205674 /* WhistlegraphAccount.swift in Sources */, 26D07334E2DEBA6705671BB0 /* WhistlegraphApp.swift in Sources */, 55A7061DE3B5F747C7BE96CE /* WhistlegraphBraincells.swift in Sources */, + F160CBA0E689C1B29B1C38FB /* WhistlegraphDebugLog.swift in Sources */, 08B1D8E0B17FC2AD2643193B /* WhistlegraphDeleteAccount.swift in Sources */, FAC2911EFFCA887527D2415F /* WhistlegraphDrawing.swift in Sources */, 73873BDDC483417E2F6E1143 /* WhistlegraphHeader.swift in Sources */, @@ -586,7 +598,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 108; + CURRENT_PROJECT_VERSION = 109; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -671,7 +683,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 108; + CURRENT_PROJECT_VERSION = 109; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; @@ -731,7 +743,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 108; + CURRENT_PROJECT_VERSION = 109; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index a89e153446..fc2d384517 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -16,7 +16,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "108" + CURRENT_PROJECT_VERSION: "109" targets: Whistlegraph: type: application -- 2.51.2