From e1be71701ab1cc82e086aa35e908036072d37ed7 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 7 Oct 2026 15:19:20 -0700 Subject: [PATCH] Whistlegraph iOS: account entry and connection flow with tests --- apple/whistlegraph/README.md | 6 ++ .../Resources/Web/account-connection.mjs | 29 ++++++ apple/whistlegraph/Resources/Web/engine.mjs | 21 ++++- .../Resources/Web/legacy-storage.mjs | 36 +++++--- .../Resources/Web/roblox-engine.mjs | 5 +- .../Resources/Web/ware-engine.mjs | 8 +- .../Sources/PreviewNavigation.swift | 6 +- .../Sources/WhistlegraphAccount.swift | 31 ++++++- .../Sources/WhistlegraphAccountEntry.swift | 89 +++++++++++++++++++ .../Sources/WhistlegraphApp.swift | 70 +++++++++++---- .../Tests/PreviewNavigationCheck.swift | 4 + .../Tests/WorkspaceBootCheck.swift | 83 +++++++++++++++++ .../Tests/account-connection.test.mjs | 45 ++++++++++ apple/whistlegraph/Tests/ware-bridge.test.cjs | 28 ++++-- apple/whistlegraph/Tests/wares.test.mjs | 27 +++++- .../UITests/AccountEntryTests.swift | 46 ++++++++++ .../Whistlegraph.xcodeproj/project.pbxproj | 16 ++++ apple/whistlegraph/project.yml | 2 + 18 files changed, 503 insertions(+), 49 deletions(-) create mode 100644 apple/whistlegraph/Resources/Web/account-connection.mjs create mode 100644 apple/whistlegraph/Sources/WhistlegraphAccountEntry.swift create mode 100644 apple/whistlegraph/Tests/WorkspaceBootCheck.swift create mode 100644 apple/whistlegraph/Tests/account-connection.test.mjs create mode 100644 apple/whistlegraph/UITests/AccountEntryTests.swift diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index c2c04a44cf..d9a21dc6dc 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -6,6 +6,12 @@ From this directory, run `./run.sh device` to bundle, build, install, and open t For an unsigned build transferred from poorslice, run `bash sign-device.sh ` before packaging or installing. This signs embedded debug libraries before the app and verifies all nested signatures. An install succeeding does not prove launch succeeds: verify the unlocked phone opens the workspace before marking a build launch-verified. +Signed-out launches show Aesel's shared blue **Log in** and green **I'm new** buttons. A verified account without a handle gets handle setup; failed verification gets Retry. Workspace controls appear after account verification. Native login and signup use PKCE and show navigation failures instead of waiting indefinitely. The workspace stays mounted behind the entry screen, preserving local pieces. + +The navigation guard accepts both the current `?whistlegraph=1` / `preview=whistlegraph` URLs and their legacy Walkieware equivalents, while retaining the `walkieware://app` storage origin. A storage facade exposes legacy keys under current names without copying the archive; partial migrations prefer existing current keys. Removing a piece removes both aliases so deleted work cannot reappear. Completed migrations keep their original deletion semantics. + +`PreviewNavigationCheck.swift`, `account-connection.test.mjs`, `wares.test.mjs`, and `ware-bridge.test.cjs` cover launch URLs, retry, account changes, full storage, and both wares. `WorkspaceBootCheck.swift` runs the actual bundle in WebKit, including an optional local storage snapshot in an ephemeral container. `AccountEntryTests` opens and cancels the real hosted login/signup forms in an ephemeral workspace without changing the phone's saved sign-in or pieces. + Typing uses AC's `compkey` sample and QWERTY pitch mapping. Enter sends the prompt, pasted line breaks become spaces, and the limit is 96 characters. Account settings control key and button sounds together. ## Story cards diff --git a/apple/whistlegraph/Resources/Web/account-connection.mjs b/apple/whistlegraph/Resources/Web/account-connection.mjs new file mode 100644 index 0000000000..e418770775 --- /dev/null +++ b/apple/whistlegraph/Resources/Web/account-connection.mjs @@ -0,0 +1,29 @@ +// Both wares report account progress to the visible native entry screen. +// An old verification must never reopen the workspace after sign-out/retry. +export class AccountConnection { + constructor({verify, changed = () => {}} = {}) { + this.verify = verify; + this.changed = changed; + this.revision = 0; + } + async connect(token, notice = '') { + const revision = ++this.revision; + if (!token) { + this.changed({status: notice ? 'failed' : 'signedOut', notice}); + return null; + } + this.changed({status: 'checking', notice: ''}); + try { + const account = await this.verify(token); + if (revision !== this.revision) return null; + this.changed({status: account.handle ? 'ready' : 'needsHandle', notice: ''}); + return account; + } catch (error) { + if (revision !== this.revision) return null; + this.changed({status: 'failed', notice: error.code === 'offline' + ? 'Could not reach Aesthetic Computer. Check your connection and retry.' + : 'Could not verify your account. Retry or log in again.'}); + return null; + } + } +} diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index b3e166f6ea..6554e4bc3f 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -1,3 +1,4 @@ +import {AccountConnection} from './account-connection.mjs'; import {fetchPersonalAccess,hasPersonalAccess} from './personal-access.mjs'; import {createAIConsentGate} from './ai-consent.mjs'; import {SourceEditor} from './source-editor.mjs'; @@ -91,10 +92,22 @@ function paintHandle(handle,colors=handleCharacterColors('@'+handle)){ accountPalette=colors; signIn.replaceChildren(...Array.from('@'+handle,(character,index)=>{const span=document.createElement('span');span.textContent=character;span.style.color='rgb('+colors[index].join(',')+')';return span;}));nativeSnapshot(); } -function accountIdentity(value){ - if(value===accountToken)return;accountToken=value;accountHandle='';personalAccess=null;syncAIConsent();braincells=null;braincellsError='';signIn.textContent=value?'…':'Sign in'; - accountVerification=value?verifyAccount(value).then(async account=>{if(accountToken!==value)return;accountHandle=account.handle;syncAIConsent();if(!accountHandle){signIn.textContent='Set handle';return;}const access=await fetchPersonalAccess(value);if(accountToken!==value)return;personalAccess=access;paintHandle(accountHandle);void refreshBraincells();const handle=accountHandle;void fetchHandleColors('@'+handle).then(colors=>{if(accountToken===value&&accountHandle===handle)paintHandle(handle,colors);}).catch(()=>{});}).catch(()=>{if(accountToken===value)signIn.textContent='Retry sign-in';}):Promise.resolve(); +const accountConnection = new AccountConnection({verify: verifyAccount, changed: state => post({action:'accountState', ...state})}); +function accountIdentity(value, notice='', force=false){ + if(value&&value===accountToken&&accountHandle&&!force)return; + accountToken=value;accountHandle='';accountPalette=[];personalAccess=null;syncAIConsent();braincells=null;braincellsError='';signIn.textContent=value?'…':'Sign in'; + nativeSnapshot(); + accountVerification=accountConnection.connect(value, notice).then(account=>{ + if(!account||accountToken!==value)return; + accountHandle=account.handle;syncAIConsent(); + if(!accountHandle){signIn.textContent='Set handle';nativeSnapshot();return;} + paintHandle(accountHandle);void refreshBraincells(); + const handle=accountHandle,revision=accountConnection.revision; + void fetchPersonalAccess(value).then(access=>{if(accountToken===value&&accountConnection.revision===revision){personalAccess=access;nativeSnapshot();}}); + void fetchHandleColors('@'+handle).then(colors=>{if(accountToken===value&&accountConnection.revision===revision)paintHandle(handle,colors);}).catch(()=>{}); + }); } + const $ = id => document.getElementById(id); const ui = document.createElement('section'); ui.id = 'live-work'; ui.hidden = true; ui.innerHTML = '

    '; @@ -521,7 +534,7 @@ window.whistlegraphEngineEvent=event=>{ if(pendingCapture?.id===event.captureID)pendingCapture.finish(event.error?Error(event.error):null,event); return; } - if(event.kind==='account') {token=event.token;if(token){if(aiConsent.allowed)musicalSocket.resume();thread?.resume();}else{musicalSocket.suspend();thread?.suspend();}window.whistlegraphAccountReady=!!token;accountIdentity(token);if(token&&pending)void ask(pending);else void resumeAttempt();} + if(event.kind==='account') {token=event.token;if(token){if(aiConsent.allowed)musicalSocket.resume();thread?.resume();}else{musicalSocket.suspend();thread?.suspend();}window.whistlegraphAccountReady=!!token;accountIdentity(token,event.notice||'',event.retry===true);if(token&&pending)void ask(pending);else void resumeAttempt();} if(event.kind==='error'){phase('Sign-in needed');log(event.text);window.whistlegraphWorkFinished?.();} if(event.kind==='previewReady'){ready=true;log('AC runtime ready');} if(event.kind==='previewEvent'){ diff --git a/apple/whistlegraph/Resources/Web/legacy-storage.mjs b/apple/whistlegraph/Resources/Web/legacy-storage.mjs index c20d92d998..ca6dd19407 100644 --- a/apple/whistlegraph/Resources/Web/legacy-storage.mjs +++ b/apple/whistlegraph/Resources/Web/legacy-storage.mjs @@ -1,11 +1,27 @@ -// Preserve immutable source and thread IDs while adopting the product name. -// Old keys remain as a recovery copy; reruns never overwrite a new key. -export function migrateLegacyStorage(storage){ - if(storage.getItem('whistlegraph-storage-migrated')==='1')return; - const keys=Array.from({length:storage.length},(_,i)=>storage.key(i)).filter(k=>k?.startsWith('walkieware-')); - for(const old of keys){ - const next='whistlegraph-'+old.slice('walkieware-'.length); - if(storage.getItem(next)===null)storage.setItem(next,storage.getItem(old)); - } - storage.setItem('whistlegraph-storage-migrated','1'); +// Expose current names without duplicating a phone's entire archive. A +// partially completed old migration may contain both; the current name wins. +// A completed migration's old recovery copies must not resurrect deleted work. +export function migrateLegacyStorage(storage) { + const legacy = storage.getItem('whistlegraph-storage-migrated') !== '1'; + const oldKey = key => legacy && key.startsWith('whistlegraph-') ? 'walkieware-' + key.slice('whistlegraph-'.length) : null; + const physicalKey = key => { + const old = oldKey(key); + return storage.getItem(key) === null && old && storage.getItem(old) !== null ? old : key; + }; + const keys = () => [...new Set(Array.from({length: storage.length}, (_, i) => storage.key(i)) + .filter(key => key && (legacy || !key.startsWith('walkieware-'))) + .map(key => legacy && key.startsWith('walkieware-') ? 'whistlegraph-' + key.slice('walkieware-'.length) : key))]; + return { + get length() { return keys().length; }, + key(index) { return keys()[index] ?? null; }, + getItem(key) { return storage.getItem(physicalKey(String(key))); }, + setItem(key, value) { storage.setItem(physicalKey(String(key)), String(value)); }, + removeItem(key) { + key = String(key); + storage.removeItem(key); + const old = oldKey(key); + if (old) storage.removeItem(old); + }, + clear() { storage.clear(); }, + }; } diff --git a/apple/whistlegraph/Resources/Web/roblox-engine.mjs b/apple/whistlegraph/Resources/Web/roblox-engine.mjs index bf5f25c958..f73edb98c1 100644 --- a/apple/whistlegraph/Resources/Web/roblox-engine.mjs +++ b/apple/whistlegraph/Resources/Web/roblox-engine.mjs @@ -1,3 +1,4 @@ +import {AccountConnection} from './account-connection.mjs'; import {AcServer} from '/easel/src/ac-server.mjs'; import {verifyAccount} from '/easel/src/account-access.mjs'; import {DEFAULT_MODEL} from './generation-policy.mjs'; @@ -9,6 +10,7 @@ import {roomPlace} from './roblox-export.mjs'; import {saveForLaunch} from './roblox-connection.mjs'; const post=body=>window.webkit.messageHandlers.whistlegraph.postMessage({id:'roblox',...body}); +const accountConnection=new AccountConnection({verify:verifyAccount,changed:state=>post({action:'accountState',...state})}); const key='whistlegraph-roblox-room',endpoint='https://aesthetic.computer/api/whistlegraph-roblox'; const versions=new PieceVersions(localStorage,key+'-versions',JSON.stringify(starterRoom())); let room=validateRoom(JSON.parse(versions.head.source)),selected='bridge',token='',handle='',owner='',busy=false,server=null; @@ -123,10 +125,11 @@ window.whistlegraphNativeCommand=command=>{ }; window.whistlegraphEngineEvent=event=>{ if(event.kind==='account'){ + if(event.token&&event.token===token&&handle&&!event.retry)return; token=event.token||'';handle='';owner='';window.whistlegraphAccountReady=!!token; if(!token&&busy){cancelled=true;server?.interrupt();} const expected=token; - if(token)void verifyAccount(token).then(account=>{if(token===expected){handle=account.handle||'';owner=account.sub;snapshot();}}).catch(()=>{}); + void accountConnection.connect(token,event.notice||'').then(account=>{if(account&&token===expected){handle=account.handle||'';owner=account.sub;snapshot();}}); snapshot(); } if(event.kind==='error'){error=event.text||'Sign-in failed';snapshot();window.whistlegraphWorkFinished?.();} diff --git a/apple/whistlegraph/Resources/Web/ware-engine.mjs b/apple/whistlegraph/Resources/Web/ware-engine.mjs index ea4342fb0a..083d525fde 100644 --- a/apple/whistlegraph/Resources/Web/ware-engine.mjs +++ b/apple/whistlegraph/Resources/Web/ware-engine.mjs @@ -1,6 +1,6 @@ import {currentWare,selectWare} from './wares.mjs'; import {migrateLegacyStorage} from './legacy-storage.mjs'; -migrateLegacyStorage(localStorage); +Object.defineProperty(window, 'localStorage', {value:migrateLegacyStorage(window.localStorage), configurable:true}); window.whistlegraphSelectWare=id=>{ if(window.whistlegraphIsBusy?.()||window.whistlegraphRecording?.())return false; try { @@ -11,4 +11,8 @@ window.whistlegraphSelectWare=id=>{ return true; } catch(error) {window.toast?.(error.message);return false;} }; -await import(currentWare(localStorage)==='roblox'?'./roblox-engine.mjs':'./engine.mjs'); +try { + await import(currentWare(localStorage)==='roblox'?'./roblox-engine.mjs':'./engine.mjs'); +} catch (error) { + window.webkit.messageHandlers.whistlegraph.postMessage({action:'startupError',text:String(error?.message||error)}); +} diff --git a/apple/whistlegraph/Sources/PreviewNavigation.swift b/apple/whistlegraph/Sources/PreviewNavigation.swift index f067635823..13306552d8 100644 --- a/apple/whistlegraph/Sources/PreviewNavigation.swift +++ b/apple/whistlegraph/Sources/PreviewNavigation.swift @@ -16,12 +16,14 @@ enum PreviewNavigation { guard let url, let mainFrame, url.user == nil, url.password == nil, url.port == nil else { return false } if mainFrame { guard url.scheme == "walkieware", url.host == "app", url.path == document.rawValue else { return false } - return document == .workspace ? query(url) == ["walkie": "1"] : url.query == nil + return document == .workspace ? [["whistlegraph": "1"], ["walkie": "1"]].contains(query(url) ?? [:]) : url.query == nil } // Creating an iframe can first navigate its empty document. if url.absoluteString == "about:blank" { return true } return url.scheme == "https" && url.host == "aesthetic.computer" && url.path == "/wipe" - && query(url) == ["noauth": "true", "noplot": "true", "nogap": "true", "nolabel": "true", "preview": "walkieware"] + && ["whistlegraph", "walkieware"].contains(where: { name in + query(url) == ["noauth": "true", "noplot": "true", "nogap": "true", "nolabel": "true", "preview": name] + }) } private static func query(_ url: URL) -> [String: String]? { guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil } diff --git a/apple/whistlegraph/Sources/WhistlegraphAccount.swift b/apple/whistlegraph/Sources/WhistlegraphAccount.swift index 33ae37f5e3..bc722d9cee 100644 --- a/apple/whistlegraph/Sources/WhistlegraphAccount.swift +++ b/apple/whistlegraph/Sources/WhistlegraphAccount.swift @@ -12,6 +12,7 @@ import UIKit private var completion: ((Result) -> Void)? private var controller: UIViewController? private var exchange: Task? + private var navigationTimeout: Task? private let identity = VerifiedAccountIdentity() func token() async throws -> String? { @@ -59,22 +60,23 @@ import UIKit } guard status == errSecSuccess else { throw NativeSignIn.failure("Could not save sign-in securely.") } } - func signIn(from view: WKWebView?, completion: @escaping (Result) -> Void) { + func signIn(from view: WKWebView?, signUp: Bool = false, completion: @escaping (Result) -> Void) { 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 + attempt = try NativeSignIn(signUp: signUp); self.completion = completion let config = WKWebViewConfiguration(); config.websiteDataStore = .nonPersistent() let web = WKWebView(frame: .zero, configuration: config); web.navigationDelegate = self - let page = UIViewController(); page.view = web; page.title = "Sign in to Aesthetic Computer" + let page = UIViewController(); page.view = web; page.title = signUp ? "Join Aesthetic Computer" : "Log 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)) + armNavigationTimeout() } catch { completion(.failure(error)) } } @objc private func cancel() { finish(.failure(NativeSignIn.failure("Sign-in cancelled. Your words are still here."))) } @@ -85,6 +87,7 @@ import UIKit case .success: DeviceActionLog.shared.record(.signIn, .succeeded) case .failure(let error): DeviceActionLog.shared.recordError(.signIn, error) } + navigationTimeout?.cancel(); navigationTimeout = nil exchange?.cancel(); exchange = nil let done = completion; completion = nil; attempt = nil controller?.dismiss(animated: true); controller = nil; done?(result) @@ -94,7 +97,8 @@ import UIKit if NativeSignIn.isCallback(url) { decisionHandler(.cancel) do { - guard var attempt = attempt else { return } + guard navigationAction.targetFrame?.isMainFrame == true, var attempt = attempt, !attempt.consumed else { return } + navigationTimeout?.cancel() let body = try attempt.exchangeBody(for: url); self.attempt = attempt exchange = Task { do { @@ -106,6 +110,25 @@ import UIKit } catch { finish(.failure(error)) } } else { decisionHandler(url.scheme == "https" ? .allow : .cancel) } } + private func armNavigationTimeout() { + navigationTimeout?.cancel() + navigationTimeout = Task { [weak self] in + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled, let self, self.controller != nil, self.attempt?.consumed != true else { return } + self.finish(.failure(NativeSignIn.failure("AC sign-in did not finish loading. Check your connection and try again."))) + } + } + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + if attempt?.consumed != true { armNavigationTimeout() } + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { navigationTimeout?.cancel() } + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { navigationFailed(error) } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { navigationFailed(error) } + private func navigationFailed(_ error: Error) { + guard !NativeSignIn.ignoresNavigationFailure(error as NSError, callbackAccepted: attempt?.consumed == true, presented: controller != nil) else { return } + finish(.failure(NativeSignIn.failure("Could not load AC sign-in. Check your connection and try again."))) + } + } final class WhistlegraphBundle: NSObject, WKURLSchemeHandler { diff --git a/apple/whistlegraph/Sources/WhistlegraphAccountEntry.swift b/apple/whistlegraph/Sources/WhistlegraphAccountEntry.swift new file mode 100644 index 0000000000..2305d0a29a --- /dev/null +++ b/apple/whistlegraph/Sources/WhistlegraphAccountEntry.swift @@ -0,0 +1,89 @@ +import SwiftUI + +enum AccountEntryStatus: String { case checking, signedOut, needsHandle, ready, failed } + +struct WhistlegraphAccountEntry: View { + @ObservedObject var session: WhistlegraphSession + @Environment(\.colorScheme) private var scheme + @Environment(\.openURL) private var openURL + + private func action(_ title: String, signUp: Bool = false, perform: @escaping () -> Void) -> some View { + Button(action: perform) { + Text(title).font(.system(size: 20, design: .monospaced)) + .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) + }.buttonStyle(CurtainAccountButtonStyle(signUp: signUp)) + } + + var body: some View { + let theme = WhistlegraphTheme(phase: .ready, dark: scheme == .dark) + GeometryReader { geometry in + ScrollView { + VStack(spacing: 30) { + ComicTitle(text: "whistlegraph", size: 36) + VStack(spacing: 18) { + if let failure = session.startupFailure { + Text(failure).multilineTextAlignment(.center) + action("Reload") { session.reloadWorkspace() } + } else if !session.workspaceReady || session.accountStatus == .checking { + ProgressView(session.workspaceReady ? "Checking your account…" : "Opening…") + .accessibilityIdentifier("account-entry-progress") + } else if session.accountStatus == .needsHandle { + action("Choose your @handle") { openURL(URL(string: "https://aesthetic.computer/handle")!) } + Button("I’ve chosen a handle") { session.restoreAccount() } + Button("Use another account") { session.signOut() } + } else { + HStack(spacing: 12) { + action("Log in") { session.signIn() }.accessibilityIdentifier("account-entry-login") + action("I'm new", signUp: true) { session.signIn(signUp: true) }.accessibilityIdentifier("account-entry-signup") + } + if session.accountStatus == .failed { + Button("Retry account verification") { session.restoreAccount() } + .accessibilityIdentifier("account-entry-retry") + } + } + if !session.accountNotice.isEmpty { + Text(session.accountNotice).font(.body).multilineTextAlignment(.center) + .accessibilityIdentifier("account-entry-notice") + } + } + } + .frame(maxWidth: 330).padding(24) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) + }.scrollIndicators(.hidden) + } + .foregroundStyle(theme.foreground).background(theme.background.ignoresSafeArea()) + .accessibilityIdentifier("account-entry") + } +} + +extension WhistlegraphSession { + var accountReady: Bool { accountStatus == .ready } + func signIn(signUp: Bool = false) { + accountNotice = "" + account.signIn(from: webView, signUp: signUp) { [weak self] result in + guard let self else { return } + switch result { + case .success(let token): + self.aiConsent.bind(subject: nil, handle: "") + self.accountStatus = .checking + self.emitEngine(["kind": "account", "token": token]) + case .failure(let error): self.accountNotice = error.localizedDescription + } + } + } + func restoreAccount(force: Bool = true) { + if force || !accountReady { accountStatus = .checking; accountNotice = "" } + let generation = account.generation + Task { + do { + let token = try await account.token() ?? "" + guard generation == account.generation else { return } + emitEngine(["kind": "account", "token": token, "retry": force]) + } catch { + guard generation == account.generation else { return } + DeviceActionLog.shared.recordError(.accountToken, error) + emitEngine(["kind": "account", "token": "", "notice": error.localizedDescription]) + } + } + } +} diff --git a/apple/whistlegraph/Sources/WhistlegraphApp.swift b/apple/whistlegraph/Sources/WhistlegraphApp.swift index cbabf69c1f..d80e06c8c2 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -25,6 +25,11 @@ struct WhistlegraphApp: App { ZStack { Color(uiColor: .systemBackground).ignoresSafeArea() WhistlegraphScreen(session: voice) + .opacity(voice.accountReady ? 1 : 0) + .allowsHitTesting(voice.accountReady) + .accessibilityElement(children: voice.accountReady ? .contain : .ignore) + .accessibilityHidden(!voice.accountReady) + if !voice.accountReady { WhistlegraphAccountEntry(session: voice) } #if DEBUG if NativeScreenFixture.mode == "audio" { Text(voice.audioTestResult).accessibilityIdentifier("audio-autoplay-result") @@ -34,7 +39,7 @@ struct WhistlegraphApp: App { .accessibilityIdentifier("consent-fixture-requests") } #endif - if !voice.workspaceReady { + if voice.accountReady && !voice.workspaceReady { VStack(spacing: 20) { Text("whistlegraph").font(.largeTitle.bold()) if let failure = voice.startupFailure { @@ -45,10 +50,10 @@ struct WhistlegraphApp: App { } } .background(ActionTouchProbe().frame(width: 0, height: 0).allowsHitTesting(false)) - .task { if !voice.isConsentFixture { await voice.braincells.start(session: voice) } } + .task { if !voice.isConsentFixture && !voice.accountEntryTest { await voice.braincells.start(session: voice) } } .onChange(of: voice.snapshot.handle) { _, _ in Task { await voice.syncAIAccount() - if !voice.isConsentFixture { await voice.braincells.accountChanged() } + if !voice.isConsentFixture && !voice.accountEntryTest { await voice.braincells.accountChanged() } } } .sheet(isPresented: $voice.showingAIConsent, onDismiss: voice.finishAIConsentPrompt) { WhistlegraphAIConsentSheet(canAllow: voice.canAllowAIConsent, @@ -62,7 +67,7 @@ struct WhistlegraphApp: App { 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 { + if value == .active && !voice.isConsentFixture && !voice.accountEntryTest { Task { await TezDisplayRate.shared.refresh() await voice.braincells.recover() @@ -88,6 +93,9 @@ struct Workspace: UIViewRepresentable { func makeCoordinator() -> WorkspaceCoordinator { WorkspaceCoordinator(session: voice) } func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() + #if DEBUG + if voice.accountEntryTest { config.websiteDataStore = .nonPersistent() } + #endif config.userContentController.addUserScript(WKUserScript(source: "window.__whistlegraphNativeShell = true;", injectionTime: .atDocumentStart, forMainFrameOnly: true)) if let data = try? JSONSerialization.data(withJSONObject: voice.aiConsent.bridge), let json = String(data: data, encoding: .utf8) { config.userContentController.addUserScript(WKUserScript(source: "window.__whistlegraphAIConsent = \(json);", injectionTime: .atDocumentStart, forMainFrameOnly: true)) @@ -207,7 +215,10 @@ struct Workspace: UIViewRepresentable { view.scrollView.contentInsetAdjustmentBehavior = .never voice.webView = view view.navigationDelegate = context.coordinator - voice.reloadWorkspace() + DispatchQueue.main.async { [weak view] in + guard let view, voice.webView === view else { return } + voice.reloadWorkspace() + } return view } func updateUIView(_ view: WKWebView, context: Context) {} @@ -235,6 +246,8 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand private var acceptedAIConsent = false @Published private(set) var localDataRevision = 0 let aiConsent = AIConsent.shared + @Published var accountStatus: AccountEntryStatus = .checking + @Published var accountNotice = "" @Published var engineReady = false private var performanceTurn = false @Published var performanceCapture = false @@ -255,6 +268,13 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand HTTPURLResponse(url: request.url!, statusCode: fails ? 503 : 200, httpVersion: nil, headerFields: nil)!) } #endif + var accountEntryTest: Bool { + #if DEBUG + return ProcessInfo.processInfo.environment["WHISTLEGRAPH_ACCOUNT_ENTRY_TEST"] == "1" + #else + return false + #endif + } var isConsentFixture: Bool { #if DEBUG return NativeScreenFixture.mode == "consent" @@ -559,6 +579,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand func signOut() { guard capturePhase == .idle else { return } account.signOut() + accountStatus = .signedOut; accountNotice = "" aiConsent.bind(subject: nil, handle: "") emitEngine(["kind": "account", "token": ""]) } @@ -576,7 +597,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand webView?.load(URLRequest(url: URL(string: "\(WhistlegraphBundle.storageScheme)://app/index.html?whistlegraph=1")!)) startupTimeout = Task { [weak self] in try? await Task.sleep(for: .seconds(12)) - guard !Task.isCancelled, let self, !self.workspaceReady else { return } + guard !Task.isCancelled, let self, !(self.workspaceReady && self.engineReady) else { return } self.startupFailure = "The workspace did not finish opening. Tap Reload to try again." DeviceActionLog.shared.record(.workspace, .failed) } @@ -800,6 +821,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand #endif traceSnapshot(next) snapshot = next; tv.updateProgress(next); engineReady = true + if workspaceReady { startupTimeout?.cancel() } case "voiceIdle": performanceCapture = false capturePhase = .idle; captureStarted = nil @@ -825,30 +847,44 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand 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 "startupError": + startupTimeout?.cancel() + startupFailure = "Could not load the workspace: " + String((body["text"] as? String ?? "Unknown error").prefix(300)) + DeviceActionLog.shared.record(.workspace, .failed) + #if DEBUG + print("[whistlegraph] startup failure: \(startupFailure ?? "")") + if let data = startupFailure?.data(using: .utf8) { + try? data.write(to: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("whistlegraph-startup-error.txt"), options: .atomic) + } + #endif case "ready": DeviceActionLog.shared.record(.workspace, .ready) - workspaceReady = true; startupFailure = nil; startupTimeout?.cancel() + workspaceReady = true; startupFailure = nil + if engineReady { startupTimeout?.cancel() } print("[whistlegraph] controls ready") if AudioBenchmark.enabled || SequenceBenchmark.enabled { UIApplication.shared.isIdleTimerDisabled = true } Task { await NetworkBenchmark.run() } + case "accountState": + #if DEBUG + if NativeScreenFixture.enabled { accountStatus = .ready; return } + #endif + guard let name = body["status"] as? String, let status = AccountEntryStatus(rawValue: name) else { return } + accountStatus = status + accountNotice = String((body["notice"] as? String ?? "").prefix(500)) case "account": if isConsentFixture { emitEngine(["kind": "account", "token": ""]); return } - Task { do { emitEngine(["kind": "account", "token": try await account.token() ?? ""]) } - catch { DeviceActionLog.shared.recordError(.accountToken, error); emitEngine(["kind": "account", "token": ""]) } } + #if DEBUG + if accountEntryTest { emitEngine(["kind": "account", "token": ""]); return } + #endif + restoreAccount(force: false) case "aiConsent": // The network guard can reject background recovery or stale account // work. Only native Send/retry/Talk gestures may present permission. 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?.aiConsent.bind(subject: nil, handle: "") - self?.emitEngine(["kind": "account", "token": token]) - case .failure(let error): self?.emitEngine(["kind": "error", "text": error.localizedDescription]) - } - } + signIn() + case "drawingCommitted": if let id = body["drawingID"] as? String, let revision = body["revision"] as? Int { drawing.consume(id: id, revision: revision) } case "render": diff --git a/apple/whistlegraph/Tests/PreviewNavigationCheck.swift b/apple/whistlegraph/Tests/PreviewNavigationCheck.swift index 8183c93f7a..9e132435c4 100644 --- a/apple/whistlegraph/Tests/PreviewNavigationCheck.swift +++ b/apple/whistlegraph/Tests/PreviewNavigationCheck.swift @@ -6,6 +6,10 @@ import Foundation func allowed(_ value: String, main: Bool? = false, document: PreviewNavigation.Document = .workspace) -> Bool { PreviewNavigation.allows(URL(string: value), mainFrame: main, document: document) } + precondition(allowed("walkieware://app/index.html?whistlegraph=1", main: true)) + precondition(allowed(runtime.replacingOccurrences(of: "preview=walkieware", with: "preview=whistlegraph"))) + precondition(PreviewNavigation.bridge(URL(string: "walkieware://app/index.html?whistlegraph=1"), mainFrame: true, document: .workspace) == .workspace) + precondition(!allowed("walkieware://app/index.html?whistlegraph=1&walkie=1", main: true)) precondition(allowed("walkieware://app/index.html?walkie=1", main: true)) precondition(allowed("walkieware://app/index.html?walkie=1#local", main: true)) precondition(allowed("walkieware://app/story.html", main: true, document: .story)) diff --git a/apple/whistlegraph/Tests/WorkspaceBootCheck.swift b/apple/whistlegraph/Tests/WorkspaceBootCheck.swift new file mode 100644 index 0000000000..afb2b8f5d5 --- /dev/null +++ b/apple/whistlegraph/Tests/WorkspaceBootCheck.swift @@ -0,0 +1,83 @@ +import AppKit +import WebKit + +// Run the shipped module graph in WebKit under the phone's custom origin. +// No credentials, inference, or persisted user data are used. +@MainActor final class WorkspaceBootCheck: NSObject, WKURLSchemeHandler, WKScriptMessageHandler, WKNavigationDelegate { + let root: URL + var view: WKWebView! + var shellReady = false, snapshotReady = false, accountReady = false + var expectedHead: Int? + init(root: URL) { self.root = root; super.init() } + func start() throws { + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + config.setURLSchemeHandler(self, forURLScheme: "walkieware") + config.userContentController.add(self, name: "whistlegraph") + var guides: [String: String] = [:] + for name in ["pieces.md", "screen.md", "hand.md", "kidlisp.md", "api.json"] { + guides["/easel/context/" + name] = try String(contentsOf: root.appendingPathComponent("easel/context/" + name), encoding: .utf8) + } + let json = String(data: try JSONSerialization.data(withJSONObject: guides), encoding: .utf8)! + config.userContentController.addUserScript(WKUserScript(source: "window.addEventListener('error',e=>webkit.messageHandlers.whistlegraph.postMessage({action:'startupError',text:e.message}));window.addEventListener('unhandledrejection',e=>webkit.messageHandlers.whistlegraph.postMessage({action:'startupError',text:String(e.reason)}));window.__whistlegraphNativeShell=true;window.__aeselGuides=\(json);", injectionTime: .atDocumentStart, forMainFrameOnly: true)) + if CommandLine.arguments.count > 2 { + let seed = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2])) + let state = try JSONSerialization.jsonObject(with: seed) as! [String: String] + if let ledger = state["whistlegraph-source-versions"] ?? state["walkieware-source-versions"], + let data = ledger.data(using: .utf8), let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + expectedHead = value["head"] as? Int + } + let encoded = String(data: try JSONSerialization.data(withJSONObject: state), encoding: .utf8)! + config.userContentController.addUserScript(WKUserScript(source: "for(const [key,value] of Object.entries(\(encoded)))localStorage.setItem(key,value);", injectionTime: .atDocumentStart, forMainFrameOnly: true)) + } + view = WKWebView(frame: CGRect(x: 0, y: 0, width: 430, height: 900), configuration: config) + view.navigationDelegate = self + view.load(URLRequest(url: URL(string: "walkieware://app/index.html?whistlegraph=1")!)) + } + func webView(_ webView: WKWebView, start task: WKURLSchemeTask) { + guard let url = task.request.url, url.host == "app" else { task.didFailWithError(URLError(.badURL)); return } + let file = root.appendingPathComponent(String(url.path.dropFirst())).standardizedFileURL + guard file.path.hasPrefix(root.path + "/"), let data = try? Data(contentsOf: file) else { + print("MISSING", url.path); task.didFailWithError(URLError(.fileDoesNotExist)); return + } + let mime = ["html":"text/html", "js":"text/javascript", "mjs":"text/javascript", "json":"application/json", "ttf":"font/ttf"][file.pathExtension] ?? "text/plain" + task.didReceive(URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: "utf-8")) + task.didReceive(data); task.didFinish() + } + func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {} + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(PreviewNavigation.allows(action.request.url, mainFrame: action.targetFrame?.isMainFrame, document: .workspace) ? .allow : .cancel) + } + func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + decisionHandler(PreviewNavigation.allows(response.response.url, mainFrame: response.isForMainFrame, document: .workspace) ? .allow : .cancel) + } + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: Any], let action = body["action"] as? String else { return } + switch action { + case "startupError": print("FAIL:", body["text"] ?? "startup error"); exit(1) + case "ready": shellReady = true + case "snapshot": + if let expectedHead { + let snapshot = body["snapshot"] as? [String: Any] + guard snapshot?["head"] as? Int == expectedHead else { print("FAIL: restored version changed"); exit(1) } + } + snapshotReady = true + case "account": view.evaluateJavaScript("window.whistlegraphEngineEvent({kind:'account',token:''})") + case "accountState": accountReady = body["status"] as? String == "signedOut" + default: break + } + if shellReady && snapshotReady && accountReady { print("PASS: WebKit loaded the bundled workspace and reached signed-out account entry"); exit(0) } + } +} + +@main struct BootCheck { + @MainActor static func main() throws { + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + let root = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL + let check = WorkspaceBootCheck(root: root) + try check.start() + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { print("FAIL: WebKit startup timed out",check.shellReady,check.snapshotReady,check.accountReady); exit(1) } + withExtendedLifetime(check) { app.run() } + } +} diff --git a/apple/whistlegraph/Tests/account-connection.test.mjs b/apple/whistlegraph/Tests/account-connection.test.mjs new file mode 100644 index 0000000000..72b109fde4 --- /dev/null +++ b/apple/whistlegraph/Tests/account-connection.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {AccountConnection} from '../Resources/Web/account-connection.mjs'; + +test('signed out, verified, missing handle, failure, and retry all finish checking', async () => { + const states = []; + let result = {sub: 'test', handle: 'maker'}, failure; + const connection = new AccountConnection({ + verify: async () => { if (failure) throw failure; return result; }, + changed: state => states.push(state), + }); + assert.equal(await connection.connect(''), null); + assert.equal(states.at(-1).status, 'signedOut'); + assert.equal((await connection.connect('test-token')).handle, 'maker'); + assert.deepEqual(states.slice(-2).map(s => s.status), ['checking', 'ready']); + result = {sub: 'test', handle: ''}; + await connection.connect('test-token'); + assert.equal(states.at(-1).status, 'needsHandle'); + failure = Object.assign(Error('private network details'), {code: 'offline'}); + await connection.connect('test-token'); + assert.equal(states.at(-1).status, 'failed'); + assert.match(states.at(-1).notice, /connection and retry/); + assert.ok(!JSON.stringify(states).includes('private network details')); + failure = null; result.handle = 'maker'; + await connection.connect('test-token'); + assert.equal(states.at(-1).status, 'ready', 'the same token can be retried'); + await connection.connect('', 'Sign in again to renew your AC session.'); + assert.equal(states.at(-1).status, 'failed', 'native renewal failures stay visible'); +}); + +test('late verification cannot undo sign-out or replace a newer retry', async () => { + const pending = [], states = []; + const connection = new AccountConnection({verify: () => new Promise((resolve, reject) => pending.push({resolve,reject})), changed: s => states.push(s)}); + const old = connection.connect('old'); + await connection.connect(''); + pending[0].resolve({sub:'old',handle:'old'}); + assert.equal(await old, null); + assert.equal(states.at(-1).status, 'signedOut'); + const first = connection.connect('same'), retry = connection.connect('same'); + pending[2].resolve({sub:'new',handle:'new'}); + assert.equal((await retry).handle, 'new'); + pending[1].reject(Error('late failure')); + assert.equal(await first, null); + assert.equal(states.at(-1).status, 'ready'); +}); diff --git a/apple/whistlegraph/Tests/ware-bridge.test.cjs b/apple/whistlegraph/Tests/ware-bridge.test.cjs index 7524e0a0d8..a6921ea0df 100644 --- a/apple/whistlegraph/Tests/ware-bridge.test.cjs +++ b/apple/whistlegraph/Tests/ware-bridge.test.cjs @@ -28,8 +28,8 @@ const server=http.createServer(async(req,res)=>{ window.webkit={messageHandlers:{whistlegraph:{postMessage:m=>{__nativeMessages.push(m);if(m.action==='render')setTimeout(()=>window.whistlegraphEngineEvent({kind:'previewEvent',event:{kind:'painted'}}),10);}}}}; const original=fetch.bind(window); window.fetch=(url,init)=>{ - if(String(url).includes('/userinfo'))return Promise.resolve(Response.json({sub:'fixture-maker'})); - if(String(url).includes('/handle?for='))return Promise.resolve(Response.json({handle:'fixture'})); + if(String(url).includes('/userinfo')){if(window.__accountFailure)return Promise.reject(Object.assign(Error('offline fixture'),{name:'TypeError'}));return Promise.resolve(Response.json({sub:'fixture-maker'}));} + if(String(url).includes('/handle?for='))return Promise.resolve(Response.json({handle:window.__noHandle?'':'fixture'})); if(String(url).includes('/api/handle-colors'))return Promise.resolve(Response.json({colors:[]})); if(String(url).includes('/api/whistlegraph-roblox')){ __roomRequests.push({method:init.method,...(init.body?{body:JSON.parse(init.body)}:{})}); @@ -43,13 +43,31 @@ const server=http.createServer(async(req,res)=>{ await page.waitForFunction(ware=>typeof whistlegraphNativeCommand==='function'&&__nativeMessages.some(m=>m.action==='snapshot'&&m.snapshot.ware===ware),{},ware); await page.evaluate(()=>{whistlegraphEngineEvent({kind:'previewReady'});whistlegraphEngineEvent({kind:'account',token:'fixture'});}); }; + const accountStates=()=>page.evaluate(()=>__nativeMessages.filter(m=>m.action==='accountState').map(m=>m.status)); + const checkAccount=async()=>{ + await page.waitForFunction(()=>__nativeMessages.filter(m=>m.action==='snapshot').at(-1)?.snapshot.handle==='fixture'); + assert.equal((await accountStates()).at(-1),'ready'); + const before=(await accountStates()).length; + await page.evaluate(()=>whistlegraphEngineEvent({kind:'account',token:'fixture'})); + assert.equal((await accountStates()).length,before,'ordinary token reuse does not close the workspace'); + await page.evaluate(()=>{window.__accountFailure=true;whistlegraphEngineEvent({kind:'account',token:'fixture',retry:true});}); + await page.waitForFunction(()=>__nativeMessages.filter(m=>m.action==='accountState').at(-1)?.status==='failed'); + await page.evaluate(()=>{window.__accountFailure=false;window.__noHandle=true;whistlegraphEngineEvent({kind:'account',token:'fixture',retry:true});}); + await page.waitForFunction(()=>__nativeMessages.filter(m=>m.action==='accountState').at(-1)?.status==='needsHandle'); + await page.evaluate(()=>{window.__noHandle=false;whistlegraphEngineEvent({kind:'account',token:'fixture',retry:true});}); + await page.waitForFunction(()=>__nativeMessages.filter(m=>m.action==='snapshot').at(-1)?.snapshot.handle==='fixture'); + await page.evaluate(()=>whistlegraphEngineEvent({kind:'account',token:''})); + assert.equal((await accountStates()).at(-1),'signedOut'); + await page.evaluate(()=>whistlegraphEngineEvent({kind:'account',token:'fixture'})); + await page.waitForFunction(()=>__nativeMessages.filter(m=>m.action==='snapshot').at(-1)?.snapshot.handle==='fixture'); + }; const last=()=>page.evaluate(()=>__nativeMessages.filter(m=>m.action==='snapshot').at(-1).snapshot); const change=async ware=>{await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.evaluate(ware=>whistlegraphNativeCommand({action:'setWare',ware}),ware)]);await ready(ware);}; - await page.goto('http://127.0.0.1:'+server.address().port+'/index.html?whistlegraph=1');await ready('piece'); + await page.goto('http://127.0.0.1:'+server.address().port+'/index.html?whistlegraph=1');await ready('piece');await checkAccount(); assert.equal((await last()).head,1); const pieceBefore=await page.evaluate(()=>localStorage.getItem('whistlegraph-source-versions')); assert.deepEqual(JSON.parse(pieceBefore),ledger,'legacy history migrated without source changes'); - await change('roblox');assert.equal((await last()).head,0); + await change('roblox');await checkAccount();assert.equal((await last()).head,0); await page.evaluate(()=>whistlegraphAsk('make it bounce'));assert.equal((await last()).head,1); const bouncing=await page.evaluate(()=>JSON.parse(localStorage.getItem('whistlegraph-roblox-room-versions'))); assert.ok(JSON.parse(bouncing.versions[1].source).objects.find(o=>o.id==='bridge').bounce>0); @@ -73,6 +91,6 @@ const server=http.createServer(async(req,res)=>{ await change('piece');assert.equal(await page.evaluate(()=>localStorage.getItem('whistlegraph-source-versions')),pieceBefore); await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.evaluate(()=>whistlegraphAsk('switch to Roblox'))]);await ready('roblox'); assert.equal((await last()).head,2,'room work survives switching away'); - assert.deepEqual(errors,[]);console.log('PASS ware bridge: migration, switching, drawing, versions, export, durable save and launch gating'); + assert.deepEqual(errors,[]);console.log('PASS ware bridge: account entry, retries, sign-out, migration, switching, drawing, versions, export, durable save and launch gating'); }finally{await browser.close();server.close();} })().catch(e=>{console.error(e);process.exitCode=1;server.close();}); diff --git a/apple/whistlegraph/Tests/wares.test.mjs b/apple/whistlegraph/Tests/wares.test.mjs index 8d58ae096c..2ac3dc939a 100644 --- a/apple/whistlegraph/Tests/wares.test.mjs +++ b/apple/whistlegraph/Tests/wares.test.mjs @@ -20,13 +20,32 @@ test('brain switching queues without overwriting artwork or changing the current test('rename preserves exact history, archive and thread identity; a new workspace does not resurrect legacy work',()=>{ const s=storage();const ledger='{"source":"// Walkieware scene v2","head":3}'; s.setItem('walkieware-source-versions',ledger);s.setItem('walkieware-source-thread','same-id');s.setItem('walkieware-archive-old','saved-archive'); - migrateLegacyStorage(s);assert.equal(s.getItem('whistlegraph-source-versions'),ledger);assert.equal(s.getItem('whistlegraph-source-thread'),'same-id');assert.equal(s.getItem('whistlegraph-archive-old'),'saved-archive'); - s.removeItem('whistlegraph-source-versions');migrateLegacyStorage(s);assert.equal(s.getItem('whistlegraph-source-versions'),null); - assert.equal(s.getItem('walkieware-source-versions'),ledger); + const current=migrateLegacyStorage(s);assert.equal(current.getItem('whistlegraph-source-versions'),ledger);assert.equal(current.getItem('whistlegraph-source-thread'),'same-id');assert.equal(current.getItem('whistlegraph-archive-old'),'saved-archive'); + assert.equal(s.getItem('walkieware-source-versions'),ledger);assert.equal(s.length,3,'opening copies nothing'); + current.removeItem('whistlegraph-source-versions');assert.equal(migrateLegacyStorage(s).getItem('whistlegraph-source-versions'),null); }); test('rename does not overwrite newer names or immutable source content',()=>{ - const s=storage();s.setItem('walkieware-source','old');s.setItem('whistlegraph-source','new');migrateLegacyStorage(s);assert.equal(s.getItem('whistlegraph-source'),'new'); + const s=storage();s.setItem('walkieware-source','old');s.setItem('whistlegraph-source','new');assert.equal(migrateLegacyStorage(s).getItem('whistlegraph-source'),'new'); const old=sceneSource({shape:'circle',color:'pink',x:.5,y:.5,size:.2,bounce:false,speed:1},'same-motion').replaceAll('Whistlegraph','Walkieware').replaceAll('whistlegraph','walkieware'); const edited=localEdit(old,'make it blue');assert.ok(edited);assert.match(edited.source,/same-motion/);assert.match(edited.source,/"color":"blue"/); assert.ok(isBasePiece('// Walkieware v0 — base color.')); }); + +test('a full store opens without writes and edits the existing legacy record',()=>{ + const s=storage();s.setItem('walkieware-source','saved');s.setItem('walkieware-archive-old','history'); + const set=s.setItem;s.setItem=(key,value)=>{if(s.getItem(key)===null)throw new DOMException('Full','QuotaExceededError');set(key,value);}; + const current=migrateLegacyStorage(s); + assert.equal(current.getItem('whistlegraph-source'),'saved'); + assert.deepEqual(Array.from({length:current.length},(_,i)=>current.key(i)),['whistlegraph-source','whistlegraph-archive-old']); + current.setItem('whistlegraph-source','edited');assert.equal(s.getItem('walkieware-source'),'edited'); + assert.throws(()=>current.setItem('brand-new-key','x'),{name:'QuotaExceededError'}); + assert.equal(current.getItem('whistlegraph-archive-old'),'history'); +}); +test('partial and completed migrations keep newer work and intentional deletions',()=>{ + const s=storage();s.setItem('walkieware-source','older');s.setItem('whistlegraph-source','newer'); + let current=migrateLegacyStorage(s);assert.equal(current.getItem('whistlegraph-source'),'newer'); + assert.equal(current.length,1,'aliases appear only once in archive enumeration'); + current.removeItem('whistlegraph-source');assert.equal(migrateLegacyStorage(s).getItem('whistlegraph-source'),null); + s.setItem('walkieware-source','old recovery copy');s.setItem('whistlegraph-storage-migrated','1'); + current=migrateLegacyStorage(s);assert.equal(current.getItem('whistlegraph-source'),null); +}); diff --git a/apple/whistlegraph/UITests/AccountEntryTests.swift b/apple/whistlegraph/UITests/AccountEntryTests.swift new file mode 100644 index 0000000000..cf91d842c1 --- /dev/null +++ b/apple/whistlegraph/UITests/AccountEntryTests.swift @@ -0,0 +1,46 @@ +import XCTest + +final class AccountEntryTests: XCTestCase { + func testLoginAndSignupEntry() { + let app = XCUIApplication() + // Use an ephemeral workspace and omit Keychain restoration. The phone's + // account and local pieces stay intact; hosted forms use the real service. + app.launchEnvironment["WHISTLEGRAPH_ACCOUNT_ENTRY_TEST"] = "1" + app.launch() + let login = app.buttons["account-entry-login"] + XCTAssertTrue(login.waitForExistence(timeout: 30), "bundled workspace must pass the navigation guard") + XCTAssertFalse(app.buttons["ware-picker"].exists, "signed-out users see account entry") + XCTAssertTrue(app.buttons["account-entry-signup"].isHittable) + capture(app, "Whistlegraph account entry") + login.tap() + XCTAssertTrue(app.navigationBars["Log in to Aesthetic Computer"].waitForExistence(timeout: 10)) + let email = app.webViews.textFields.firstMatch + XCTAssertTrue(email.waitForExistence(timeout: 30), "real login form loaded") + capture(app, "Whistlegraph login form") + app.navigationBars.buttons["Cancel"].tap() + XCTAssertTrue(login.waitForExistence(timeout: 10)) + app.buttons["account-entry-signup"].tap() + XCTAssertTrue(app.navigationBars["Join Aesthetic Computer"].waitForExistence(timeout: 10)) + XCTAssertTrue(email.waitForExistence(timeout: 30), "real signup form loaded") + capture(app, "Whistlegraph signup form") + app.navigationBars.buttons["Cancel"].tap() + XCTAssertTrue(login.waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["account-entry-signup"].isHittable) + } + + func testInstalledWorkspaceStarts() { + let app = XCUIApplication() + app.launch() + let outcome = expectation(for: NSPredicate { _, _ in + app.buttons["ware-picker"].exists || app.buttons["account-entry-login"].exists || app.buttons["account-entry-retry"].exists + }, evaluatedWith: nil) + XCTAssertEqual(XCTWaiter.wait(for: [outcome], timeout: 45), .completed) + XCTAssertFalse(app.buttons["Reload"].exists) + capture(app, "Whistlegraph restored launch") + } + + private func capture(_ app: XCUIApplication, _ name: String) { + let image = XCTAttachment(screenshot: app.screenshot()) + image.name = name; image.lifetime = .keepAlways; add(image) + } +} diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index a74f27d929..41b151b593 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -44,6 +44,8 @@ 6122FB433EC952206856DEBD /* WhistlegraphPrivacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AAFA5A6A0627C23CA67DA4 /* WhistlegraphPrivacy.swift */; }; 620B74996F82DC86FA07B6DB /* CodeSyntax.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2AB1140648295BB7905317 /* CodeSyntax.swift */; }; 63AC2EF656BB908FC6B195DA /* WhistlegraphMint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FEBB51736F61D93DD1A7A /* WhistlegraphMint.swift */; }; + 6460BCE9CE823AF90631AE50 /* CurtainAccountButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ACCBA671810FCCC117EBAD /* CurtainAccountButtonStyle.swift */; }; + 66FC5E366944092753BE774F /* WhistlegraphAccountEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA9EE38034DDB5CA6D4A373 /* WhistlegraphAccountEntry.swift */; }; 67D674B7AF9E1E056C10BEDF /* PreviewFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CE7F71D92E8328A7BDA44D /* PreviewFormatTests.swift */; }; 6AB03522A9C86406EB456624 /* AIConsentRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD07129E1ECC6AC505F2FD72 /* AIConsentRecord.swift */; }; 73873BDDC483417E2F6E1143 /* WhistlegraphHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BC2C1DC5E036F689EA59561 /* WhistlegraphHeader.swift */; }; @@ -51,6 +53,7 @@ 77291EEE768914D5F5EDB84B /* PlaybackWord.swift in Sources */ = {isa = PBXBuildFile; fileRef = E97104E13862EE30444D07C9 /* PlaybackWord.swift */; }; 77EC5FCFB7F9BF4A6BD4F94B /* NativeSignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D4A924CCE7B4BEC62E98A9 /* NativeSignIn.swift */; }; 7C8A0DCC5322399983CC8E43 /* StoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E86622F951A1F67F055D69F9 /* StoryCache.swift */; }; + 8395B3E4B12986EAE391B9C8 /* AeselButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCA7E38115A43D2BE4A25C1 /* AeselButtonStyle.swift */; }; 86581AE590F8A301268220CA /* CostDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 919CF3F0659F42F321B22145 /* CostDisplay.swift */; }; 87EDDAE34B992D308236F1A9 /* StoryCardsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B4655A00B6DC810F7203D1 /* StoryCardsTests.swift */; }; 8A9F944BA95C971D70ADDF6B /* VersionNarrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D137824491A01D4C19417B8 /* VersionNarrator.swift */; }; @@ -73,6 +76,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 */; }; + EE469E48E164A4E317B262F4 /* AccountEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2014384D32064E6DABEAB7 /* AccountEntryTests.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 */ @@ -112,6 +116,7 @@ 477B640525BC627AFB40D7A7 /* StoryCardStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryCardStyle.swift; sourceTree = ""; }; 478EF62AE288CE06A64A3EA4 /* PreviewFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewFormat.swift; sourceTree = ""; }; 49854384EFBA11ABDE4F70C6 /* StoryVoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryVoice.swift; sourceTree = ""; }; + 4AA9EE38034DDB5CA6D4A373 /* WhistlegraphAccountEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphAccountEntry.swift; sourceTree = ""; }; 4BA75145769FC4401A11BBE8 /* Web */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Web; path = Resources/Web; sourceTree = SOURCE_ROOT; }; 4BBA0E45689010CB41A6A8FF /* NetworkBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkBenchmark.swift; sourceTree = ""; }; 4D3B156A3435E646EE0E6EEA /* PlaybackCaption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCaption.swift; sourceTree = ""; }; @@ -121,6 +126,7 @@ 5D233DBB6F87184A91C13D41 /* MusicalInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicalInput.swift; sourceTree = ""; }; 5DACE2604B34CCB5EB3EE8AC /* WhistlegraphTV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphTV.swift; sourceTree = ""; }; 5DF0A171A5771A7A1FD56852 /* VisualCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualCapture.swift; sourceTree = ""; }; + 62ACCBA671810FCCC117EBAD /* CurtainAccountButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurtainAccountButtonStyle.swift; sourceTree = ""; }; 65C2FC346829168FF4AEEED3 /* StoryPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryPreview.swift; sourceTree = ""; }; 6D137824491A01D4C19417B8 /* VersionNarrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionNarrator.swift; sourceTree = ""; }; 6D7D7F240AB7ADAA1340FE5C /* StoreCreditDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreditDelivery.swift; sourceTree = ""; }; @@ -135,6 +141,7 @@ 8F9B92C3457F07429C8C2600 /* UtteranceRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtteranceRecording.swift; sourceTree = ""; }; 919CF3F0659F42F321B22145 /* CostDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CostDisplay.swift; sourceTree = ""; }; 99D9A8C801D3B9D1192D1143 /* WhistlegraphDrawing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphDrawing.swift; sourceTree = ""; }; + 9FCA7E38115A43D2BE4A25C1 /* AeselButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselButtonStyle.swift; sourceTree = ""; }; A542DD3D25782F331540474E /* Whistlegraph.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Whistlegraph.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5904655414F61AE746DF6D5 /* WhistlegraphUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WhistlegraphUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A95FEBB51736F61D93DD1A7A /* WhistlegraphMint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphMint.swift; sourceTree = ""; }; @@ -154,6 +161,7 @@ E66B3ADC4D6D837FABC53B8F /* WhistlegraphScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphScreen.swift; sourceTree = ""; }; E86622F951A1F67F055D69F9 /* StoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryCache.swift; sourceTree = ""; }; E97104E13862EE30444D07C9 /* PlaybackWord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackWord.swift; sourceTree = ""; }; + ED2014384D32064E6DABEAB7 /* AccountEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountEntryTests.swift; sourceTree = ""; }; EF1916E8033C031E046E760C /* PreviewNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewNavigation.swift; sourceTree = ""; }; FCE365B860DBD53E42DB2824 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; FD07129E1ECC6AC505F2FD72 /* AIConsentRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConsentRecord.swift; sourceTree = ""; }; @@ -227,6 +235,7 @@ 6D137824491A01D4C19417B8 /* VersionNarrator.swift */, 5DF0A171A5771A7A1FD56852 /* VisualCapture.swift */, 060870DF76C6FC078A48A276 /* WhistlegraphAccount.swift */, + 4AA9EE38034DDB5CA6D4A373 /* WhistlegraphAccountEntry.swift */, BA5F188938A81E704BB7F9F9 /* WhistlegraphApp.swift */, 10BFA0F39F2F514E799291F6 /* WhistlegraphBraincells.swift */, 15699BE38BA8C0B44DDABDD3 /* WhistlegraphDebugLog.swift */, @@ -249,6 +258,7 @@ A8D1DCE6550AE55C396F659B /* UITests */ = { isa = PBXGroup; children = ( + ED2014384D32064E6DABEAB7 /* AccountEntryTests.swift */, 6FB1D789A7001E472597133B /* AIConsentTests.swift */, 3F98F7D3CE551FC6458D1F37 /* HeaderSheetsTests.swift */, 85CE7F71D92E8328A7BDA44D /* PreviewFormatTests.swift */, @@ -263,6 +273,8 @@ BCA8AB62D544DD3CEB0F7DF9 /* Sources */ = { isa = PBXGroup; children = ( + 9FCA7E38115A43D2BE4A25C1 /* AeselButtonStyle.swift */, + 62ACCBA671810FCCC117EBAD /* CurtainAccountButtonStyle.swift */, 21D4A924CCE7B4BEC62E98A9 /* NativeSignIn.swift */, ); name = Sources; @@ -401,12 +413,14 @@ files = ( 6AB03522A9C86406EB456624 /* AIConsentRecord.swift in Sources */, 4A8B9BCE5403659FFB71EA65 /* AccountDeletionClient.swift in Sources */, + 8395B3E4B12986EAE391B9C8 /* AeselButtonStyle.swift in Sources */, 2F15D5A580A90A18E92D90F1 /* AudioBenchmark.swift in Sources */, 0438F503606B4A37262B7CCC /* BrainSettings.swift in Sources */, 2F01E350A7FF55359B002344 /* ButtonSounds.swift in Sources */, 620B74996F82DC86FA07B6DB /* CodeSyntax.swift in Sources */, 9DBA1AF2AD7E3EDF69C8132A /* CodeTicker.swift in Sources */, 86581AE590F8A301268220CA /* CostDisplay.swift in Sources */, + 6460BCE9CE823AF90631AE50 /* CurtainAccountButtonStyle.swift in Sources */, 6037259BAADEA55B4760A19A /* DeviceActionLog.swift in Sources */, 123228FADA6696AB354306C7 /* InputButtonLabels.swift in Sources */, 5D29324F2AF09B53070D7644 /* LiveTranscription.swift in Sources */, @@ -441,6 +455,7 @@ 8A9F944BA95C971D70ADDF6B /* VersionNarrator.swift in Sources */, 749AC25AE02AAE4BCDA5683A /* VisualCapture.swift in Sources */, AFDE98CF6460438CE2205674 /* WhistlegraphAccount.swift in Sources */, + 66FC5E366944092753BE774F /* WhistlegraphAccountEntry.swift in Sources */, 26D07334E2DEBA6705671BB0 /* WhistlegraphApp.swift in Sources */, 55A7061DE3B5F747C7BE96CE /* WhistlegraphBraincells.swift in Sources */, F160CBA0E689C1B29B1C38FB /* WhistlegraphDebugLog.swift in Sources */, @@ -464,6 +479,7 @@ buildActionMask = 2147483647; files = ( 3459C827F644F26BCABD5534 /* AIConsentTests.swift in Sources */, + EE469E48E164A4E317B262F4 /* AccountEntryTests.swift in Sources */, 19717448D7598ADAABBF739E /* HeaderSheetsTests.swift in Sources */, 67D674B7AF9E1E056C10BEDF /* PreviewFormatTests.swift in Sources */, 87EDDAE34B992D308236F1A9 /* StoryCardsTests.swift in Sources */, diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index 3665f497be..e5e9b9c2d1 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -24,6 +24,8 @@ targets: sources: - Sources - ../aesel/Sources/NativeSignIn.swift + - ../aesel/Sources/CurtainAccountButtonStyle.swift + - ../aesel/Sources/AeselButtonStyle.swift - path: Resources/PrivacyInfo.xcprivacy buildPhase: resources - path: Resources/Assets.xcassets -- 2.51.2