From 1700dd8b3f3881fad3cb74d3cea5d25bbf87cd59 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 18:03:43 -0700 Subject: [PATCH] Whistlegraph 107: add StoreKit purchases and privacy controls --- apple/whistlegraph/Info.plist | 2 +- apple/whistlegraph/README.md | 169 ++++++++++++++- .../Resources/PrivacyInfo.xcprivacy | 68 ++++++ .../whistlegraph/Resources/Web/ai-consent.mjs | 74 +++++++ apple/whistlegraph/Resources/Web/engine.mjs | 87 +++++++- .../Resources/Web/inference-error.mjs | 9 + apple/whistlegraph/Resources/Web/native.js | 4 +- .../Resources/Web/source-editor.mjs | 75 +++++++ .../Sources/AIConsentRecord.swift | 23 ++ .../Sources/AccountDeletionClient.swift | 77 +++++++ .../whistlegraph/Sources/BrainSettings.swift | 31 ++- apple/whistlegraph/Sources/CostDisplay.swift | 3 + apple/whistlegraph/Sources/PaymentBuild.swift | 6 + .../Sources/PreviewNavigation.swift | 35 +++ .../Sources/StoreCreditDelivery.swift | 63 ++++++ apple/whistlegraph/Sources/StoryPreview.swift | 14 +- apple/whistlegraph/Sources/StoryVoice.swift | 41 +++- .../Sources/TezosBraincells.swift | 9 +- .../Sources/UtteranceRecording.swift | 2 +- .../Sources/WhistlegraphAccount.swift | 18 +- .../Sources/WhistlegraphApp.swift | 96 ++++++++- .../Sources/WhistlegraphBraincells.swift | 147 +++++++++++++ .../Sources/WhistlegraphDeleteAccount.swift | 66 ++++++ .../Sources/WhistlegraphHeader.swift | 18 +- .../Sources/WhistlegraphMint.swift | 3 + .../Sources/WhistlegraphPrivacy.swift | 65 ++++++ .../Sources/WhistlegraphScreen.swift | 2 + .../Sources/WhistlegraphSource.swift | 154 ++++++++++++++ .../Sources/WorkspaceCoordinator.swift | 8 + .../Tests/AIConsentRecordCheck.swift | 25 +++ .../Tests/AccountDeletionClientCheck.swift | 68 ++++++ .../Tests/PreviewNavigationCheck.swift | 38 ++++ .../Tests/StoreCreditDeliveryCheck.swift | 97 +++++++++ apple/whistlegraph/Tests/ai-consent.test.mjs | 200 ++++++++++++++++++ .../Tests/inference-error.test.mjs | 17 ++ .../whistlegraph/Tests/native-bridge.test.cjs | 4 +- .../Tests/source-editor-bridge.test.cjs | 104 +++++++++ .../whistlegraph/Tests/source-editor.test.mjs | 162 ++++++++++++++ .../UITests/HeaderSheetsTests.swift | 27 +++ .../UITests/WhistlegraphMintTests.swift | 3 + .../Whistlegraph.xcodeproj/project.pbxproj | 172 ++++++++++++++- .../xcschemes/WhistlegraphInternal.xcscheme | 118 +++++++++++ apple/whistlegraph/project.yml | 34 ++- apple/whistlegraph/run.sh | 13 +- 44 files changed, 2390 insertions(+), 61 deletions(-) create mode 100644 apple/whistlegraph/Resources/PrivacyInfo.xcprivacy create mode 100644 apple/whistlegraph/Resources/Web/ai-consent.mjs create mode 100644 apple/whistlegraph/Resources/Web/inference-error.mjs create mode 100644 apple/whistlegraph/Resources/Web/source-editor.mjs create mode 100644 apple/whistlegraph/Sources/AIConsentRecord.swift create mode 100644 apple/whistlegraph/Sources/AccountDeletionClient.swift create mode 100644 apple/whistlegraph/Sources/PaymentBuild.swift create mode 100644 apple/whistlegraph/Sources/PreviewNavigation.swift create mode 100644 apple/whistlegraph/Sources/StoreCreditDelivery.swift create mode 100644 apple/whistlegraph/Sources/WhistlegraphBraincells.swift create mode 100644 apple/whistlegraph/Sources/WhistlegraphDeleteAccount.swift create mode 100644 apple/whistlegraph/Sources/WhistlegraphPrivacy.swift create mode 100644 apple/whistlegraph/Sources/WhistlegraphSource.swift create mode 100644 apple/whistlegraph/Tests/AIConsentRecordCheck.swift create mode 100644 apple/whistlegraph/Tests/AccountDeletionClientCheck.swift create mode 100644 apple/whistlegraph/Tests/PreviewNavigationCheck.swift create mode 100644 apple/whistlegraph/Tests/StoreCreditDeliveryCheck.swift create mode 100644 apple/whistlegraph/Tests/ai-consent.test.mjs create mode 100644 apple/whistlegraph/Tests/inference-error.test.mjs create mode 100644 apple/whistlegraph/Tests/source-editor-bridge.test.cjs create mode 100644 apple/whistlegraph/Tests/source-editor.test.mjs create mode 100644 apple/whistlegraph/Whistlegraph.xcodeproj/xcshareddata/xcschemes/WhistlegraphInternal.xcscheme diff --git a/apple/whistlegraph/Info.plist b/apple/whistlegraph/Info.plist index 8cf11499c2..63da3d04ab 100644 --- a/apple/whistlegraph/Info.plist +++ b/apple/whistlegraph/Info.plist @@ -2,7 +2,7 @@ CFBundleDevelopmentRegionenCFBundleExecutable$(EXECUTABLE_NAME)CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)CFBundleNameWhistlegraphCFBundleDisplayNameWhistlegraphCFBundlePackageTypeAPPLCFBundleShortVersionString$(MARKETING_VERSION)CFBundleVersion$(CURRENT_PROJECT_VERSION)UILaunchScreen NSPhotoLibraryAddUsageDescriptionSave the story videos you export to your camera roll. NSMicrophoneUsageDescriptionHold the talk button to tell Whistlegraph what to make. Recording stops when you release it. -NSSpeechRecognitionUsageDescriptionWhistlegraph turns your spoken request into text. Speech recognition runs on this iPhone. Recordings stay on this phone for playback. +NSSpeechRecognitionUsageDescriptionWhistlegraph turns speech into text on this iPhone. Optional cloud speech sends audio to OpenAI only after you allow it in AI & privacy. UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIUserInterfaceStyleDarkITSAppUsesNonExemptEncryption NSLocalNetworkUsageDescriptionFind AC OS devices on your Wi-Fi and project your Whistlegraph piece to them. NSBonjourServices_http._tcp diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index 8c8f3aa739..e9fa6d623f 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -139,9 +139,11 @@ Buy braincells with tez opens an AC checkout in the browser, where Beacon pairs with Temple or another Tezos wallet. Choose $3 / 600,000 or $5 / 1,000,000 braincells in checkout before connecting. The user signs and approves in their wallet. The app stores only a checkout capability bound to the signed-in handle and -reconciles on return; credit is granted by server verification. The production -purchase link is limited to the US App Store storefront; Debug permits device -testing. The API flag `AC_TEZOS_CREDITS_ENABLED` controls new purchases. +reconciles on return; credit is granted by server verification. These experimental +purchase and mint flows are compiled only into the explicit `Internal` +configuration for direct Xcode device testing. Default `Debug` and `Release` +builds use StoreKit. The API flag `AC_TEZOS_CREDITS_ENABLED` controls new web +purchases; it cannot enable native checkout in a distributed build. `HeaderSheetsTests/testPhoneCostUnitToggle` checks all three units and persistence on the paired phone without initiating a purchase. @@ -160,12 +162,15 @@ and preserves the selected version. Generation receives the actual preview size. The preference affects the working preview; story-card output retains its existing format. -Debug builds signed in as @jeffrey expose Pieces → Mint on HEN. Choose the title, -editions and royalties, then pack the saved version and review the single HTML -file inside the app. A Tezos wallet signs an ownership message before minting. +The explicit `Internal` configuration, signed in as @jeffrey, exposes Pieces → +Mint on HEN. Choose the title, editions and royalties, then pack the saved +version. The corrected Teia package is a ZIP with `index.html`, an animated GIF +preview, and a still PNG thumbnail, pinned as an IPFS directory. Review the +artwork and both previews before minting. The standalone HTML remains available +as an export. A Tezos wallet signs an ownership message before minting. The pilot API requires `WHISTLEGRAPH_MINT_PILOT=true` and checks cloud ownership, version and source hash. AC login tokens stay out of mint links. Artwork and -metadata are public on IPFS; wallet ownership gates minting, not viewing. +metadata are public on IPFS; the wallet authorizes the on-chain mint. A pending wallet request cannot be dispatched again or discarded. Reopening the preview checks the unique metadata URI on mainnet, requires three confirmations @@ -187,3 +192,153 @@ version, verifies the tree renders, and stops before wallet connection or signin `WhistlegraphMintTests/testPhoneSpinningTreeTempleHandoff` opens the installed Temple app and stops before wallet approval or signing. The mint preview uses AC's IPFS gateway; its sandbox cannot access the mint page or wallet storage. + + +## App Store monetization + +The first global release uses StoreKit consumables for hosted AC inference. +`computer.aesthetic.walkieware.braincells.1m` grants 1,000,000 nonexpiring +braincells to the signed-in AC account. Its button uses Apple's localized +`Product.displayPrice`; an unavailable product shows an unavailable state. +The live and story previews allow only their bundled root page and the AC +artwork runtime; document navigation into Aesel, remote checkout, wallet app +schemes, or new windows is denied. Runtime resource requests, the separate +sign-in browser, and exported artwork remain unchanged. Exhausted-credit errors +direct users to Brain settings, rather than another client's payment flow. +The Braincells / USD / Tezos selector remains a usage-cost conversion. It does +not transfer money, redeem credits for currency, or prove wallet ownership. +Its historical USD conversion is not a quote for the App Store purchase price. + +Before presenting Apple's purchase sheet, the app obtains its account's stable +UUID from authenticated `POST /api/whistlegraph-iap` with `action: "account"` and +passes it through StoreKit's `appAccountToken`. It sends a verified transaction's +signed JWS with `action: "redeem"`. AC verifies the purchase and grants credits; +the client finishes only after the matching transaction, account, environment, +and credit amount have been acknowledged. Pending approvals, offline deliveries, +account switches, and mismatched receipts remain unfinished for retry. A retry +uses the same transaction rather than purchasing again. Credits remain in the +AC account after reinstall; Check pending purchases recovers undelivered +consumables, rather than claiming that StoreKit restores already consumed packs. + +Temple checkout and NFT minting are development experiments. Select +`WhistlegraphInternal` in Xcode or run `CONFIGURATION=Internal ./run.sh device` +to test them. This configuration retains the existing app container, so it +replaces the installed development build. It cannot be archived; every shared +scheme archives `Release`. The implementation and callback handlers are compiled +out of default Debug and Release. No server flag, account role, receipt check, +or reviewer detection can turn those native paths back on. Neither a Tezos +wallet nor an NFT unlocks creation, formats, export, or inference. A future +minting service needs a separately reviewed payment design; a wallet used to +sign an authorized transaction is not an entitlement to app features. + +Apple's current [payment rules](https://developer.apple.com/app-store/review/guidelines/#payments) +require IAP for in-app digital functionality and prohibit wallet/NFT ownership +from unlocking app functionality. They allow U.S. storefront external-purchase +links without an entitlement. That is a possible later storefront-specific web +checkout path, not permission to enable every Temple/NFT operation globally. +Other storefront programs have separate agreements and requirements. This +release chooses one StoreKit purchase flow across storefronts. It does not rely +on being a reader app or a free companion exemption. App Review decides approval. + +Before submission: + +1. Confirm the existing App Store Connect record's bundle ID is + `computer.aesthetic.walkieware` and obtain its real numeric Apple ID. Create + the consumable `computer.aesthetic.walkieware.braincells.1m`, set availability + and price tiers, add localization and the purchase screenshot, and complete + the paid-app agreement, tax and banking requirements. No product or app ID is + provisioned by this code. Keep App Store promotion for this consumable off + until `PurchaseIntent` can establish the AC account before purchase. +2. Deploy the reviewed `/api/whistlegraph-iap` backend and configure + `WHISTLEGRAPH_APPLE_ID` and `WHISTLEGRAPH_IAP_ENABLED=true`. The sales flag + controls preparation for new purchases; valid existing transactions and + refund notifications remain redeemable/reconcilable when sales are paused. + Deploy the shared artwork runtime update that suppresses remote preview + console logs while retaining local render evidence. Keep Sandbox + redemption restricted to explicitly allowlisted AC test/review accounts with + `WHISTLEGRAPH_IAP_ALLOW_SANDBOX=true` and `WHISTLEGRAPH_IAP_SANDBOX_USERS`. + Configure App Store Server Notifications V2 for production and sandbox at + `https://aesthetic.computer/api/whistlegraph-iap`; verify signed refund events + reverse the grant correctly, including refunds before client redemption. +3. Run an App Store sandbox purchase on a device, pending approval, cancellation, + network failure after payment, relaunch/redelivery, account switching, + duplicate redemption, reinstall, full/partial refund and refund-reversal tests. + The backend orders verified refund state by Apple [signedDate](https://developer.apple.com/documentation/appstoreservernotifications/signeddate), + updates the credit wallet idempotently and restores reversed refunds. The + scheduled recovery worker retries pending verified notifications every minute + with a 15-minute failure backoff. Verify production and + sandbox cannot share transaction claims or leak free test credits to normal + accounts. Xcode local StoreKit signatures are not production purchase proof. +4. Supply App Review a working AC account on the sandbox allowlist and explain + Brain → 1,000,000 braincells, consumption by hosted inference, nonexpiring paid + balance, and the display-only currencies. State that distributed builds have + no wallet purchases, NFT mint/list/transfer service, or token-gated features. + Submit the consumable with the app and test the exact Release archive. +5. Complete App Store Connect privacy responses from the shipped data flows, + including optional cloud audio and images, account-linked content and purchase + records. Verify the privacy policy URL, in-app deletion/recovery, consent + revocation and source editor on the exact device build. Apple must still + assess generated-code execution under 2.5.2 and 4.7; a source editor alone + does not establish eligibility. The distributed app has private creation and + system export, with no community feed. Adding hosted third-party browsing, + public posts or an in-app catalog requires its own content controls, reporting, + blocking and age-rating review. + +Local delivery-state check, from `apple/whistlegraph` (does not buy or submit anything): + +```sh +xcrun swiftc -j 2 Sources/StoreCreditDelivery.swift Tests/StoreCreditDeliveryCheck.swift -o /tmp/whistlegraph-store-credit-check +/tmp/whistlegraph-store-credit-check +xcrun swiftc -j 2 Sources/PreviewNavigation.swift Tests/PreviewNavigationCheck.swift -o /tmp/whistlegraph-preview-navigation-check +/tmp/whistlegraph-preview-navigation-check +node --test Tests/inference-error.test.mjs +``` + + +## AI permissions, source and account deletion (build 107) + +Brain → AI & privacy discloses the text, source/history, drawings, cropped preview +images and sound measurements used for AI creation, with an explicit opt-in. +Permissions are versioned and stored per AC account on this device. Generation, +visual review, musical interpretation and personal-provider sessions wait for +permission; revocation cancels active sending. Cloud OpenAI audio transcription +and ElevenLabs caption narration have separate opt-ins, both off by default. +Without them, transcription and synthesized narration stay on-device. Saved +recordings still supply the user's original story audio. Data already received +by a provider cannot be recalled by the switch. + +Brain → View and edit source opens the complete selected revision, supports +copy/share and saves a local draft. Applying an edit validates source and runtime +feedback before saving a new child version; failure restores the previous +preview. Manual edits use no AI or credits and work signed out. The preview +bridge gives generated frames only artwork/render/export feedback; account +credentials, sign-in and native share actions require the bundled main document. + +Account → Delete account first reads the account-wide loss preview. Confirmation +locks the AC account and schedules its server purge after the grace period. +Only a successful schedule acknowledgement clears the local sign-in, web data, +source drafts, recordings, drawing and story caches. App-created local exports +are removed; copies saved to Photos or shared elsewhere remain. A partial local +filesystem failure is reported separately from the successful server schedule. +No live deletion is exercised by the automated tests. + +`Resources/PrivacyInfo.xcprivacy` ships in the app bundle. Required-reason APIs +are own-container UserDefaults (`CA92.1`), own-container file timestamps +(`C617.1`) and elapsed in-app uptime (`35F9.1`), following Apple's +[required-reason API documentation](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api). +It declares no tracking and account-linked functionality data for email/user ID, +content, drawn/preview images, optional cloud audio, purchase history and +generation diagnostics. This manifest does not provision App Store Connect +privacy answers. The embedded artwork runtime skips Google/PostHog analytics; +its local preview evidence remains available for correctness checks. + +Additional local checks: + +```sh +xcrun swiftc -j 2 Sources/AIConsentRecord.swift Tests/AIConsentRecordCheck.swift -o /tmp/whistlegraph-ai-consent-check +/tmp/whistlegraph-ai-consent-check +xcrun swiftc -j 2 Sources/AccountDeletionClient.swift Tests/AccountDeletionClientCheck.swift -o /tmp/whistlegraph-account-deletion-check +/tmp/whistlegraph-account-deletion-check +node --test Tests/ai-consent.test.mjs Tests/source-editor.test.mjs +node Tests/source-editor-bridge.test.cjs +``` diff --git a/apple/whistlegraph/Resources/PrivacyInfo.xcprivacy b/apple/whistlegraph/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..5177c8a29d --- /dev/null +++ b/apple/whistlegraph/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,68 @@ + + + + + NSPrivacyTracking + NSPrivacyTrackingDomains + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasonsCA92.1 + + + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasonsC617.1 + + + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons35F9.1 + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeEmailAddress + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeUserID + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeOtherUserContent + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeAudioData + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypePurchaseHistory + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeOtherDiagnosticData + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality + + + + diff --git a/apple/whistlegraph/Resources/Web/ai-consent.mjs b/apple/whistlegraph/Resources/Web/ai-consent.mjs new file mode 100644 index 0000000000..cc4ffa2180 --- /dev/null +++ b/apple/whistlegraph/Resources/Web/ai-consent.mjs @@ -0,0 +1,74 @@ +const aiRequest = input => { + let url; + try { url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url, + globalThis.location?.href || 'https://aesthetic.computer'); } catch { return false; } + if (url.origin === 'https://aesthetic.computer') return ['/api/easel-inference', '/api/easel-musical-jev'].includes(url.pathname); + if (url.origin === 'https://help.aesthetic.computer' && url.pathname.startsWith('/api/aesel/sessions')) { + // Revocation must still be able to cancel an already-started remote turn. + return !/^\/api\/aesel\/sessions\/[^/]+\/interrupt$/.test(url.pathname); + } + return false; +}; + +export function createAIConsentGate({fetch, allowed = false, onRequired = () => {}}) { + let permitted = allowed === true; + const active = new Set(); + function requireConsent() { + if (permitted) return; + onRequired(); + throw Error('Allow AI creation in AI & privacy before sending this request.'); + } + return { + get allowed() { return permitted; }, + require: requireConsent, + setAllowed(value) { + permitted = value === true; + if (!permitted) { for (const controller of active) controller.abort(); active.clear(); } + }, + async fetch(input, init = {}) { + if (!aiRequest(input)) return fetch(input, init); + requireConsent(); + const controller = new AbortController(); + const upstream = init.signal || (typeof input === 'object' ? input.signal : null); + const abort = () => controller.abort(upstream?.reason); + const assertActive = () => { + if (controller.signal.aborted) throw controller.signal.reason; + }; + let reader, output; + const cleanup = () => { + active.delete(controller); + upstream?.removeEventListener('abort', abort); + controller.signal.removeEventListener('abort', abortStream); + }; + const abortStream = () => { + output?.error(controller.signal.reason); + void reader?.cancel(controller.signal.reason).catch(() => {}); + cleanup(); + }; + controller.signal.addEventListener('abort', abortStream, {once: true}); + if (upstream?.aborted) abort(); else upstream?.addEventListener('abort', abort, {once: true}); + try { + assertActive(); + active.add(controller); + const response = await fetch(input, {...init, signal: controller.signal}); + if (controller.signal.aborted) await response.body?.cancel(controller.signal.reason).catch(() => {}); + assertActive(); + if (!response.body) { cleanup(); return response; } + reader = response.body.getReader(); + const body = new ReadableStream({ + start(stream) { output = stream; }, + async pull(stream) { + try { + assertActive(); + const result = await reader.read(); + assertActive(); + if (result.done) { cleanup(); stream.close(); } else stream.enqueue(result.value); + } catch (error) { cleanup(); stream.error(error); } + }, + async cancel(reason) { cleanup(); controller.abort(); await reader.cancel(reason); }, + }); + return new Response(body, {status: response.status, statusText: response.statusText, headers: response.headers}); + } catch (error) { cleanup(); throw error; } + }, + }; +} diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index 4803a5a411..15ad262e26 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -1,3 +1,6 @@ +import {createAIConsentGate} from './ai-consent.mjs'; +import {SourceEditor} from './source-editor.mjs'; +import {inferenceError} from './inference-error.mjs'; import {withDrawing,inputData,drawingImage,drawingContent} from './drawing-input.mjs'; import {checkedPrompt} from './prompt-limit.mjs'; import {reviewVisualResult,reviewWithRepair} from './visual-review.mjs'; @@ -25,6 +28,18 @@ import {runnablePrefix,partialString,streamedEdits,streamedCode} from './stream- import * as vfs from '/easel/phone/shim/fs.mjs'; const post = body => window.webkit.messageHandlers.walkie.postMessage({id:'engine',...body}); +let consentState = window.__whistlegraphAIConsent || {}; +const aiConsent = createAIConsentGate({fetch: globalThis.fetch.bind(globalThis), onRequired: () => post({action:'aiConsent'})}); +globalThis.fetch = aiConsent.fetch; +function syncAIConsent() { + const allowed = consentState.creation === true && !!accountHandle && consentState.handle === accountHandle && accountToken === token; + aiConsent.setAllowed(allowed); + if (!allowed) { + turnCancelled = true; visualController?.abort(); server?.interrupt(); + musicalAdvisor.cancel(); musicalSocket.suspend(); + } else { musicalSocket.resume(); } +} + const benchmark=(event,fields={})=>{post({action:'benchmark',event,fields});window.__walkiewareSequenceEvent?.(event,fields);}; const file = '/piece/walkieware.mjs'; const storageKey=window.__whistlegraphFixture?'whistlegraph-fixture-source':window.__walkiewareSpace?'walkieware-space-source':window.__walkiewareLocalSequence?'walkieware-local-source':window.__walkiewareSequence?'walkieware-sequence-source':window.__walkiewareBenchmark?'walkieware-benchmark-source':'walkieware-source'; @@ -71,8 +86,8 @@ function paintHandle(handle,colors=handleCharacterColors('@'+handle)){ 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='';braincells=null;braincellsError='';signIn.textContent=value?'…':'Sign in'; - accountVerification=value?verifyAccount(value).then(account=>{if(accountToken!==value)return;accountHandle=account.handle;if(!accountHandle){signIn.textContent='Set handle';return;}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(); + if(value===accountToken)return;accountToken=value;accountHandle='';syncAIConsent();braincells=null;braincellsError='';signIn.textContent=value?'…':'Sign in'; + accountVerification=value?verifyAccount(value).then(account=>{if(accountToken!==value)return;accountHandle=account.handle;syncAIConsent();if(!accountHandle){signIn.textContent='Set handle';return;}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 $ = id => document.getElementById(id); const ui = document.createElement('section'); ui.id = 'live-work'; ui.hidden = true; @@ -337,8 +352,8 @@ function makeServer({repair=false}={}){ if(method==='item/started')benchmark('toolStarted',{tool:params.item?.tool||params.item?.type}); if(method==='item/completed' && params.item?.status?.startsWith('failed')) {log(params.item.status);benchmark('toolFailed',{message:params.item.status});} if(method==='turn/usage'){log('Usage · '+(params.usage.output_tokens??0)+' output tokens');nativeSnapshot();} - if(method==='turn/completed'){turnSucceeded=!params.turn.error&¶ms.turn.status==='completed';turnError=params.turn.error?.message||(params.turn.status==='interrupted'?'Stopped':''); - if(params.turn.error){phase('Could not finish');log(params.turn.error.message);} + if(method==='turn/completed'){turnSucceeded=!params.turn.error&¶ms.turn.status==='completed';turnError=inferenceError(params.turn.error)||(params.turn.status==='interrupted'?'Stopped':''); + if(params.turn.error){phase('Could not finish');log(turnError);} else if(params.turn.status==='interrupted'){phase('Stopped');log('Stopped by you');} else {phase(painted?'Checking picture…':source?'Waiting for preview…':'No piece written');log('Model finished');} } @@ -351,7 +366,8 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text document.body.classList.add('live-mode'); pending=text;ui.hidden=false;$('live-request').textContent=displayText;started=performance.now();events.length=0; $('live-stop').hidden=false;$('live-details').open=false; - if(!token){phase('Sign in to make software');log('Uses your AC braincells. Speech stays on device; submitted words go to AC.');post({action:'signIn'});return;} + if(!token){phase('Sign in to make software');log('AI permissions are managed in AI & privacy.');post({action:'signIn'});return;} + if(!aiConsent.allowed){pending='';phase('Allow AI creation in AI & privacy');post({action:'aiConsent'});return;} try { activeAttempt=saveAttempt(localStorage,storageKey,recovered||{id:crypto.randomUUID(),text,displayText,localText,parent:versions.head.id,baseSource:versions.head.source,retries:0,status:'working'}); } catch { phase('Could not save request for recovery');return; } @@ -425,7 +441,7 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text } }finally{clearTimeout(deadline);} - }catch(error){turnError=error.message;phase('Could not start');log(error.message);} + }catch(error){turnError=inferenceError(error);phase('Could not start');log(turnError);} finally{ if(noChange){await finishReceipt('unchanged');localStorage.removeItem(storageKey+'-inflight');activeAttempt=null;lastAttempt={...lastAttempt,status:'unchanged'};end();phase('Already there');benchmark('localEditUnchanged');return;} if(!turnSucceeded&&!turnRuntimeFailed&&!turnCancelled&&turnStarter&&starterPainted){ @@ -462,6 +478,9 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text } async function resumeAttempt(manual=false){ if(busy||!token||!ready||!painted||!versions||(!manual&&!recoveryPending))return; + const expectedToken=token; + await accountVerification; + if(busy||token!==expectedToken||!aiConsent.allowed||(!manual&&!recoveryPending))return; recoveryPending=false; const attempt=claimAttempt(localStorage,storageKey,versions.value,manual); if(!attempt){if(manual)phase('Could not resume: version changed or request unavailable');return;} @@ -473,7 +492,7 @@ window.walkiewareEngineEvent=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){musicalSocket.resume();thread?.resume();}else{musicalSocket.suspend();thread?.suspend();}window.walkiewareAccountReady=!!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.walkiewareAccountReady=!!token;accountIdentity(token);if(token&&pending)void ask(pending);else void resumeAttempt();} if(event.kind==='error'){phase('Sign-in needed');log(event.text);window.walkiewareWorkFinished?.();} if(event.kind==='previewReady'){ready=true;log('AC runtime ready');} if(event.kind==='previewEvent'){ @@ -506,16 +525,23 @@ window.walkiewareAskDrawing=(text,drawing)=>{ catch(error){phase('Could not read drawing');log(error.message);return Promise.resolve();} }; let musicalTurn=0; -const musicalSocket=new MusicalInputSocket({token:()=>token,onEvent:benchmark}); -document.addEventListener('visibilitychange',()=>{if(document.hidden){musicalSocket.suspend();thread?.suspend();}else{musicalSocket.resume();thread?.resume();}}); -const musicalAdvisor=new MusicalInputAdvisor({fetchImpl:musicalSocket.fetch,token:()=>token,onEvent:(event,fields)=>{benchmark(event,fields);if(event==='jevDecision')log('Jev · '+fields.choice);}}); +const musicalSocket=new MusicalInputSocket({token:()=>aiConsent.allowed?token:null,onEvent:benchmark}); +document.addEventListener('visibilitychange',()=>{if(document.hidden){musicalSocket.suspend();thread?.suspend();}else{if(aiConsent.allowed)musicalSocket.resume();thread?.resume();}}); +const musicalAdvisor=new MusicalInputAdvisor({fetchImpl:(...args)=>{aiConsent.require();return musicalSocket.fetch(...args);},token:()=>aiConsent.allowed?token:null,onEvent:(event,fields)=>{benchmark(event,fields);if(event==='jevDecision')log('Jev · '+fields.choice);}}); window.walkiewareInputStart=()=>{musicalTurn++;musicalAdvisor.reset();}; window.walkiewareInputCancel=()=>{musicalTurn++;musicalAdvisor.cancel();}; -window.walkiewareObserveSound=input=>{musicalPrompt(input);if(wantsSoundEvidence(input.transcript))musicalAdvisor.observe(input);}; +window.walkiewareObserveSound=input=>{musicalPrompt(input);if(aiConsent.allowed&&wantsSoundEvidence(input.transcript))musicalAdvisor.observe(input);}; window.walkiewareAskSound=async input=>{ + if(!aiConsent.allowed){post({action:'aiConsent'});return;} const prompt=withDrawing(musicalPrompt(input),input.drawing);const useSound=!!input.drawing||wantsSoundEvidence(input.transcript);if(!useSound)musicalAdvisor.cancel();phase(useSound?'Interpreting sound…':'Sending…'); return ask(prompt,`${input.transcript||'Sound'} · ${(input.sound.durationMs/1000).toFixed(1)} seconds`,input.drawing||!useSound||localEdit(source,input.transcript)?null:musicalAdvisor.finish(input),input.drawing?null:instantPiece(input.transcript),input.drawing?'':input.transcript); }; +window.walkiewareSetAIConsent = value => { consentState = value || {}; syncAIConsent(); }; +window.walkiewareForgetLocalData = () => { + consentState={}; aiConsent.setAllowed(false); turnCancelled=true; + visualController?.abort(); server?.interrupt(); musicalAdvisor.cancel(); musicalSocket.suspend(); thread?.suspend(); + token=accountToken=accountHandle=''; localStorage.clear(); +}; window.walkiewareIsBusy=()=>busy; window.walkiewareHasReview=()=>false; if(!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!window.__walkiewareLocalSequence&&!window.__walkiewareDisableThread)try{if(initializeBasePiece(localStorage,storageKey))lastAttempt=null;}catch(error){phase('Could not initialize piece');log(error.message);} @@ -546,6 +572,45 @@ if(typeof window.__walkiewareFixtureBusy==='string'){ outputStream='export function paint({ wipe, ink, circle, screen }) {\n wipe("#151838");\n ink("#4653c6");\n circle(screen.width / 2, screen.height / 2, 40, true);'; $('live-code').textContent=outputStream;phase('Writing…');$('live-stop').hidden=false;review(false); } +window.walkiewareSourceEditor = new SourceEditor({ + state: () => { + if (!versions) throw Error('The piece is still loading.'); + return {piece: thread?.identity.id || storageKey, code: thread?.identity.code || '', + version: versions.head.id, source: versions.head.source, busy, + recording: !!window.walkiewareRecording?.()}; + }, + checks: sourceChecks, + hash: hashSource, + begin: () => { + busy = true; previous = source; recoveryPending = false; + turnRuntimeFailed = false; turnCancelled = false; turnError = ''; + clearTimeout(compileTimer); compileTimer = null; provisional = ''; + server?.close(); server = null; review(false); phase('Checking source edit…'); + }, + render: value => { render(value); return renderID; }, + inspect: () => ({requestID: renderID, sourceHash: previewHash, + rendered: painted && lastPaintedSource === previewSource, + logs: feedback?.logs || [], runtimeFailed: turnRuntimeFailed, cancelled: turnCancelled}), + commit: value => { + const version = versions.commit(value); + source = previous = version.source; lastAttempt = null; + vfs.mount(file, source); saved(); + return version; + }, + restore: () => { + // Read the current durable head, so even a concurrent checkout is preserved. + source = previous = versions.head.source; + turnRuntimeFailed = false; turnCancelled = false; + vfs.mount(file, source); + render(source || 'export function paint({wipe}) {wipe("black");}'); + }, + finish: committed => { + busy = false; review(false); + phase(committed ? `v${versions.head.id} · Ready to play` : 'Could not apply source edit'); + updateFeed(); window.walkiewareWorkFinished?.(); + }, +}); + updateFeed(); if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!window.__walkiewareDisableThread) { const label=codeLabel;label.setAttribute('aria-live','polite'); diff --git a/apple/whistlegraph/Resources/Web/inference-error.mjs b/apple/whistlegraph/Resources/Web/inference-error.mjs new file mode 100644 index 0000000000..aa249779d0 --- /dev/null +++ b/apple/whistlegraph/Resources/Web/inference-error.mjs @@ -0,0 +1,9 @@ +// Shared relay errors can name another client's checkout. This app's purchase +// entry is Brain settings, including when the user's free allowance runs out. +export function inferenceError(error) { + const message = typeof error?.message === 'string' ? error.message : ''; + if (/^Out of braincells\b/i.test(message)) { + return 'Out of braincells. Open Brain settings to add braincells, or wait for the free allowance to reset at midnight UTC.'; + } + return message; +} diff --git a/apple/whistlegraph/Resources/Web/native.js b/apple/whistlegraph/Resources/Web/native.js index 6c10435e01..b289a16f5c 100644 --- a/apple/whistlegraph/Resources/Web/native.js +++ b/apple/whistlegraph/Resources/Web/native.js @@ -102,8 +102,8 @@ note.textContent = ''; const blocked = () => window.walkiewareIsBusy ? window.walkiewareIsBusy() : gameMode === 'review'; $('info').querySelector('h2').textContent = 'Whistlegraph'; - $('info').querySelectorAll('p')[0].textContent = 'Hold to talk. Release to make. Your words are transcribed on the iPhone; recordings stay on this phone for playback. Allow Speech and Microphone access the first time, then hold again.'; - $('info').querySelectorAll('p')[1].textContent = 'Your ww code identifies this piece. Source, versions, and live errors sync privately to your AC account so your other devices and agents can inspect and edit it. Drawings and measured sound cues travel with your requests. Each generated edit also sends cropped, timed preview frames to AC for a visual check before saving. Recordings stay on this phone.'; + $('info').querySelectorAll('p')[0].textContent = 'Hold to talk. Release to make. Device speech is the default. Optional cloud speech sends audio to OpenAI only after you allow it in AI & privacy. Recordings are saved on this phone for playback. Allow Speech and Microphone access the first time, then hold again.'; + $('info').querySelectorAll('p')[1].textContent = 'Your ww code identifies this piece. Source, versions, and live errors sync privately to your AC account so your other devices and agents can inspect and edit it. Drawings and measured sound cues travel with your requests. Each generated edit also sends cropped, timed preview frames to AC for a visual check before saving. Raw recordings leave this phone only when cloud speech is enabled.'; $('speak').setAttribute('aria-label', 'Hold to talk to Whistlegraph'); $('export').hidden = true; $('reset').hidden = true; diff --git a/apple/whistlegraph/Resources/Web/source-editor.mjs b/apple/whistlegraph/Resources/Web/source-editor.mjs new file mode 100644 index 0000000000..e9327bc249 --- /dev/null +++ b/apple/whistlegraph/Resources/Web/source-editor.mjs @@ -0,0 +1,75 @@ +// Manual edits use the selected immutable version, never the inference loop. +export class SourceEditor { + constructor({state, checks, hash, begin, render, inspect, commit, restore, finish, + now = () => performance.now(), wait = ms => new Promise(resolve => setTimeout(resolve, ms)), + timeout = 8000, settle = 750}) { + Object.assign(this, {state, checks, hash, begin, render, inspect, commit, restore, finish, now, wait, timeout, settle}); + this.applying = false; + } + + async read() { + const base = this.state(); + const sourceHash = await this.hash(base.source); + this.assertCurrent(base); + return {piece: base.piece, code: base.code || '', version: base.version, source: base.source, sourceHash}; + } + + assertCurrent(base) { + const current = this.state(); + if (current.piece !== base.piece || current.version !== base.version || current.source !== base.source) { + throw Error('The selected version changed. Reopen Source to edit that version; your draft is saved.'); + } + return current; + } + + async apply({piece, version, sourceHash, source} = {}) { + if (this.applying) throw Error('A source edit is already being checked.'); + this.applying = true; + let started = false, committed = false; + try { + const base = this.state(); + if (base.busy || base.recording) throw Error('Finish the current request or recording before editing source.'); + if (typeof source !== 'string' || !source.trim()) throw Error('Enter a complete JavaScript piece.'); + if (new TextEncoder().encode(source).length >= 500000) throw Error('Source edits must be smaller than 500 KB.'); + if (piece !== base.piece || version !== base.version || sourceHash !== await this.hash(base.source)) { + throw Error('The selected version changed. Reopen Source to edit that version; your draft is saved.'); + } + const findings = this.checks(source); + if (findings.length) throw Error(findings.map(finding => finding.message).join('\n')); + const candidateHash = await this.hash(source); + const current = this.assertCurrent(base); + if (current.busy || current.recording) throw Error('Finish the current request or recording before editing source.'); + if (source === base.source) return {...base, sourceHash: candidateHash, changed: false}; + started = true; + this.begin(); + const requestID = this.render(source); + const deadline = this.now() + this.timeout; + let paintedAt = null; + while (true) { + this.assertCurrent(base); + const proof = this.inspect(); + if (proof.cancelled) throw Error('Source edit stopped. The previous version was restored.'); + if (proof.requestID !== requestID) throw Error('The preview changed while checking this edit.'); + if (proof.sourceHash && proof.sourceHash !== candidateHash) throw Error('The preview does not match this source.'); + const runtimeError = proof.logs?.find(entry => entry.level === 'error'); + if (proof.runtimeFailed || runtimeError) throw Error(runtimeError?.text || 'The edited piece could not run.'); + if (proof.sourceHash === candidateHash && proof.rendered) { + paintedAt ??= this.now(); + if (this.now() - paintedAt >= this.settle) break; + } else paintedAt = null; + if (this.now() >= deadline) throw Error('The edited piece did not render. The previous version was restored.'); + await this.wait(25); + } + this.assertCurrent(base); + const saved = this.commit({source, parent: base.version, request: 'Manual source edit', layers: 1}); + committed = true; + return {piece: base.piece, code: base.code || '', version: saved.id, source, sourceHash: candidateHash, changed: true}; + } catch (error) { + if (started && !committed) this.restore(); + throw error; + } finally { + try { if (started) this.finish(committed); } + finally { this.applying = false; } + } + } +} diff --git a/apple/whistlegraph/Sources/AIConsentRecord.swift b/apple/whistlegraph/Sources/AIConsentRecord.swift new file mode 100644 index 0000000000..8ec5a5c9e7 --- /dev/null +++ b/apple/whistlegraph/Sources/AIConsentRecord.swift @@ -0,0 +1,23 @@ +import Foundation +import CryptoKit + +struct AIConsentRecord: Codable, Equatable { + static let version = 1 + var version = Self.version + var creation = false + var cloudSpeech = false + var cloudNarration = false + var updatedAt = Date() + static func key(subject: String) -> String { + "whistlegraph-ai-consent:" + SHA256.hash(data: Data(subject.utf8)).map { String(format: "%02x", $0) }.joined() + } + static func read(subject: String?, defaults: UserDefaults = .standard) -> Self { + guard let subject, !subject.isEmpty, let data = defaults.data(forKey: key(subject: subject)), + let value = try? JSONDecoder().decode(Self.self, from: data), value.version == version else { return Self() } + return value + } + func save(subject: String, defaults: UserDefaults = .standard) { + guard !subject.isEmpty, let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.key(subject: subject)) + } +} diff --git a/apple/whistlegraph/Sources/AccountDeletionClient.swift b/apple/whistlegraph/Sources/AccountDeletionClient.swift new file mode 100644 index 0000000000..b275900d50 --- /dev/null +++ b/apple/whistlegraph/Sources/AccountDeletionClient.swift @@ -0,0 +1,77 @@ +import Foundation + +/// No local data is erased until the server confirms account deletion is scheduled. +@MainActor final class AccountDeletionClient { + struct Preview: Decodable { + let handle: String + let email: String + let graceDays: Int + let handleHoldDays: Int + let braincells: Double + let counts: [String: Int] + } + struct Schedule: Decodable { + let state: String + let purgeAfter: String + let mailed: Bool? + var localErasureError: String? + var purgeDate: Date? { + let formatter = ISO8601DateFormatter(); formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: purgeAfter) ?? ISO8601DateFormatter().date(from: purgeAfter) + } + } + struct Identity { let bearer: String; let generation: Int } + private(set) var preview: Preview? + private var identity: Identity? + private var confirming = false + private let credential: () async throws -> Identity? + private let current: (Identity) -> Bool + private let send: (URLRequest) async throws -> (Data, URLResponse) + private let clear: () async throws -> Void + private let endpoint = URL(string: "https://aesthetic.computer/api/delete-erase-and-forget-me")! + init(credential: @escaping () async throws -> Identity?, current: @escaping (Identity) -> Bool, + send: @escaping (URLRequest) async throws -> (Data, URLResponse) = { try await URLSession.shared.data(for: $0) }, + clear: @escaping () async throws -> Void) { + self.credential = credential; self.current = current; self.send = send; self.clear = clear + } + private func failure(_ text: String) -> NSError { NSError(domain: "AccountDeletion", code: 1, userInfo: [NSLocalizedDescriptionKey: text]) } + private func request(_ method: String, identity: Identity) async throws -> Data { + guard current(identity) else { throw failure("Your account changed. Read the deletion preview again.") } + var request = URLRequest(url: method == "GET" ? endpoint.appending(queryItems: [.init(name: "preview", value: "")]) : endpoint) + request.httpMethod = method; request.timeoutInterval = 45 + request.setValue("Bearer \(identity.bearer)", forHTTPHeaderField: "Authorization") + if method == "POST" { request.httpBody = Data("{}".utf8); request.setValue("application/json", forHTTPHeaderField: "Content-Type") } + let (data, response) = try await send(request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + throw failure(body?["message"] as? String ?? "Could not complete this request. Your local work has not been erased.") + } + return data + } + func load() async throws -> Preview { + preview = nil; identity = nil + guard let auth = try await credential() else { throw failure("Sign in to preview account deletion.") } + let value = try JSONDecoder().decode(Preview.self, from: await request("GET", identity: auth)) + guard current(auth), value.graceDays >= 0, value.braincells.isFinite, value.braincells >= 0 else { + throw failure("The account preview changed. Try again.") + } + preview = value; identity = auth; return value + } + func confirm() async throws -> Schedule { + guard !confirming else { throw failure("Deletion confirmation is already in progress.") } + confirming = true; defer { confirming = false } + guard preview != nil, let original = identity, current(original), let auth = try await credential(), + auth.generation == original.generation, current(auth) else { + throw failure("Read the deletion preview for the current account before confirming.") + } + var schedule = try JSONDecoder().decode(Schedule.self, from: await request("POST", identity: auth)) + guard schedule.state == "scheduled", schedule.purgeDate != nil else { + throw failure("The server has not confirmed the deletion schedule. Your local work is still here.") + } + guard current(auth) else { throw failure("Deletion was scheduled for the previous account. The current account's local work was not erased.") } + do { try await clear() } + catch { schedule.localErasureError = "Deletion is scheduled, but some local files could not be erased: " + error.localizedDescription } + identity = nil; preview = nil + return schedule + } +} diff --git a/apple/whistlegraph/Sources/BrainSettings.swift b/apple/whistlegraph/Sources/BrainSettings.swift index 8b137eba92..d6f4a0f5c4 100644 --- a/apple/whistlegraph/Sources/BrainSettings.swift +++ b/apple/whistlegraph/Sources/BrainSettings.swift @@ -66,6 +66,14 @@ struct BrainSettings: View { ForEach(PreviewFormat.allCases) { Text($0.rawValue).tag($0) } }.pickerStyle(.segmented).disabled(disabled).accessibilityIdentifier("brain-preview-format") } header: { Label("Preview", systemImage: "eye") } + Section { + NavigationLink { WhistlegraphSourceSheet(session: session) } label: { + Label("View and edit source", systemImage: "curlybraces") + }.disabled(!session.engineReady).accessibilityIdentifier("brain-source") + NavigationLink { WhistlegraphPrivacySheet(session: session) } label: { + Label("AI & privacy", systemImage: "hand.raised") + }.accessibilityIdentifier("brain-privacy") + } if let inference = session.snapshot.inference { Section { Picker("Model", selection: Binding(get: { inference.selection }, set: { session.command("setModel", text: $0) })) { @@ -95,7 +103,10 @@ struct BrainSettings: View { Text(inference.braincellsError).foregroundStyle(.secondary) } Button("Refresh") { session.command("refreshBraincells"); Task { await prices.refresh() } } + BraincellPurchase(purchase: session.braincells, signedIn: !session.snapshot.handle.isEmpty) + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG TezosPurchaseButton(session: session, purchase: session.tezosBraincells) + #endif } if let usage = inference.usage { Section(session.snapshot.busy ? "This request" : "Last request") { @@ -118,14 +129,28 @@ struct BrainSettings: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } }.presentationDetents([.medium, .large]) - .task { await prices.refresh(); await session.tezosBraincells.prepare(); await session.tezosBraincells.refresh(session: session) } + .task { + await prices.refresh() + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG + await session.tezosBraincells.prepare() + await session.tezosBraincells.refresh(session: session) + #endif + } .task(id: costUnit) { if costUnit == .tezos { await prices.refresh() } } .onChange(of: scenePhase) { _, phase in - if phase == .active { Task { await prices.refresh(); await session.tezosBraincells.refresh(session: session) } } + if phase == .active { + Task { + await prices.refresh() + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG + await session.tezosBraincells.refresh(session: session) + #endif + } + } } } } +#if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG private struct TezosPurchaseButton: View { @ObservedObject var session: WhistlegraphSession @ObservedObject var purchase: TezosBraincells @@ -140,6 +165,8 @@ private struct TezosPurchaseButton: View { } } +#endif + private extension ISO8601DateFormatter { static var fullPrecision: ISO8601DateFormatter { let formatter = ISO8601DateFormatter() diff --git a/apple/whistlegraph/Sources/CostDisplay.swift b/apple/whistlegraph/Sources/CostDisplay.swift index a9f66f6543..2d86520f15 100644 --- a/apple/whistlegraph/Sources/CostDisplay.swift +++ b/apple/whistlegraph/Sources/CostDisplay.swift @@ -70,6 +70,9 @@ struct CostUnitPicker: View { Picker("Cost unit", selection: $unit) { ForEach(CostUnit.allCases) { Text($0.label).tag($0) } }.pickerStyle(.segmented).accessibilityIdentifier("brain-cost-unit") + if unit != .braincells { + Text("Estimated service value.").font(.footnote).foregroundStyle(.secondary) + } if unit == .tezos { if let rate = prices.rate, rate.isFresh, let date = rate.date { Text("1 tez = \(rate.usdPerTez.formatted(.currency(code: "USD").precision(.fractionLength(4)))) · \(date.formatted(date: .omitted, time: .shortened)) · TzKT") diff --git a/apple/whistlegraph/Sources/PaymentBuild.swift b/apple/whistlegraph/Sources/PaymentBuild.swift new file mode 100644 index 0000000000..6f27640304 --- /dev/null +++ b/apple/whistlegraph/Sources/PaymentBuild.swift @@ -0,0 +1,6 @@ +// Wallet checkout and NFT minting are experiments installed directly with Xcode. +// App Store and TestFlight builds use Release. Do not enable these with an +// account check, remote flag, receipt environment, or App Review detection. +#if WHISTLEGRAPH_INTERNAL_PAYMENTS && !DEBUG +#error("Internal wallet testing requires a Debug build and cannot ship in Release.") +#endif diff --git a/apple/whistlegraph/Sources/PreviewNavigation.swift b/apple/whistlegraph/Sources/PreviewNavigation.swift new file mode 100644 index 0000000000..f067635823 --- /dev/null +++ b/apple/whistlegraph/Sources/PreviewNavigation.swift @@ -0,0 +1,35 @@ +import Foundation + +/// These WebViews render artwork; account and purchase UI have separate native +/// entry points. Resource requests are unaffected by this document allowlist. +enum PreviewNavigation { + enum Document: String { case workspace = "/index.html", story = "/story.html" } + + enum BridgeScope { case workspace, artwork, none } + static func bridge(_ url: URL?, mainFrame: Bool, document: Document) -> BridgeScope { + guard allows(url, mainFrame: mainFrame, document: document) else { return .none } + if mainFrame { return document == .workspace ? .workspace : .none } + return url?.scheme == "https" ? .artwork : .none + } + + static func allows(_ url: URL?, mainFrame: Bool?, document: Document) -> Bool { + 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 + } + // 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"] + } + private static func query(_ url: URL) -> [String: String]? { + guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil } + var result: [String: String] = [:] + for item in items { + guard result[item.name] == nil, let value = item.value else { return nil } + result[item.name] = value + } + return result + } +} diff --git a/apple/whistlegraph/Sources/StoreCreditDelivery.swift b/apple/whistlegraph/Sources/StoreCreditDelivery.swift new file mode 100644 index 0000000000..b9afb424ca --- /dev/null +++ b/apple/whistlegraph/Sources/StoreCreditDelivery.swift @@ -0,0 +1,63 @@ +import Foundation + +/// The server owns the balance. StoreKit remains the retry queue until the +/// matching AC account acknowledges a durable grant for this transaction. +@MainActor final class StoreCreditDelivery { + nonisolated static let productID = "computer.aesthetic.walkieware.braincells.1m" + nonisolated static let credits = 1_000_000 + struct Receipt { + let id: String + let productID: String + let accountToken: UUID? + let environment: String + let revoked: Bool + let jws: String + } + struct Credential { + let bearer: String + let generation: Int + } + enum Action { case account, redeem(String) } + enum Outcome: Equatable { + case added, alreadyAdded, ignored, deferred(String) + } + private struct Account: Decodable { let appAccountToken: UUID } + private struct Grant: Decodable { + let credited: Bool + let transactionId: String + let credits: Int + let environment: String + } + private var inFlight = Set() + + func deliver(_ receipt: Receipt, + credential: () async throws -> Credential?, + isCurrent: (Credential) -> Bool, + request: (Action, Credential) async throws -> Data, + finish: () async -> Void) async -> Outcome { + guard receipt.productID == Self.productID else { return .ignored } + guard !receipt.revoked else { return .deferred("This purchase was revoked. Contact support if the balance is incorrect.") } + guard inFlight.insert(receipt.id).inserted else { return .ignored } + defer { inFlight.remove(receipt.id) } + do { + guard let auth = try await credential(), isCurrent(auth) else { + return .deferred("Sign in to the AC account used for this purchase to add your braincells.") + } + let account = try JSONDecoder().decode(Account.self, from: await request(.account, auth)) + guard isCurrent(auth) else { return .deferred("Your account changed. Sign in to the purchasing account and retry.") } + guard receipt.accountToken == account.appAccountToken else { + return .deferred("This purchase belongs to another AC account. Sign in to that account and retry.") + } + let grant = try JSONDecoder().decode(Grant.self, from: await request(.redeem(receipt.jws), auth)) + guard grant.transactionId == receipt.id, grant.credits == Self.credits, + ["Production", "Sandbox"].contains(grant.environment), grant.environment == receipt.environment else { + return .deferred("AC has not confirmed this purchase. Your purchase is saved for retry.") + } + guard isCurrent(auth) else { return .deferred("Your account changed. Sign in to the purchasing account and retry.") } + await finish() + return grant.credited ? .added : .alreadyAdded + } catch { + return .deferred("Your purchase is saved for retry. " + error.localizedDescription) + } + } +} diff --git a/apple/whistlegraph/Sources/StoryPreview.swift b/apple/whistlegraph/Sources/StoryPreview.swift index fb4d944e74..b8d08897ba 100644 --- a/apple/whistlegraph/Sources/StoryPreview.swift +++ b/apple/whistlegraph/Sources/StoryPreview.swift @@ -2,7 +2,7 @@ import SwiftUI import WebKit // Story playback owns its runtime so a slide cannot replace generation/review pixels. -@MainActor final class StoryPreview: NSObject, WKScriptMessageHandler { +@MainActor final class StoryPreview: NSObject, WKScriptMessageHandler, WKNavigationDelegate { weak var session: WhistlegraphSession? private(set) var view: WKWebView! private var frame: WKFrameInfo? @@ -24,11 +24,20 @@ import WebKit config.userContentController.addUserScript(WKUserScript(source: WhistlegraphPreview.script, injectionTime: .atDocumentStart, forMainFrameOnly: false)) config.userContentController.addUserScript(WKUserScript(source: StoryTape.script, injectionTime: .atDocumentStart, forMainFrameOnly: false)) view = WKWebView(frame: .zero, configuration: config) + view.navigationDelegate = self view.isOpaque = false; view.backgroundColor = .clear view.scrollView.isScrollEnabled = false view.scrollView.contentInsetAdjustmentBehavior = .never view.load(URLRequest(url: URL(string: "walkieware://app/story.html")!)) } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(PreviewNavigation.allows(navigationAction.request.url, + mainFrame: navigationAction.targetFrame?.isMainFrame, document: .story) ? .allow : .cancel) + } + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + decisionHandler(PreviewNavigation.allows(navigationResponse.response.url, + mainFrame: navigationResponse.isForMainFrame, document: .story) ? .allow : .cancel) + } func present(version: Int, source: String) { self.version = version; self.source = source; request += 1; session?.storyStatus = "Loading story runtime"; render() } @@ -50,8 +59,7 @@ import WebKit _ = try await view.callAsyncJavaScript("if (!window.whistlegraphStoryTape) throw Error('Canvas tape is unavailable'); await window.whistlegraphStoryTape[action](value ?? id);", arguments: ["action":action,"value":arguments["value"] ?? NSNull(),"id":arguments["id"] ?? NSNull()], in: frame, contentWorld: .page) } func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { - guard !message.frameInfo.isMainFrame, message.frameInfo.request.url?.host == "aesthetic.computer", - message.frameInfo.request.url?.scheme == "https", let body = message.body as? [String: Any] else { return } + guard PreviewNavigation.bridge(message.frameInfo.request.url, mainFrame: message.frameInfo.isMainFrame, document: .story) == .artwork, let body = message.body as? [String: Any] else { return } if body["action"] as? String == "previewReady" { frame = message.frameInfo; session?.storyStatus = "Story runtime ready"; render() } if body["action"] as? String == "storyTape" { session?.storyTapeEvent?(body) } if body["action"] as? String == "previewEvent", let event = body["event"] as? [String: Any], diff --git a/apple/whistlegraph/Sources/StoryVoice.swift b/apple/whistlegraph/Sources/StoryVoice.swift index 60e7bc5aa8..31d7e4d7a1 100644 --- a/apple/whistlegraph/Sources/StoryVoice.swift +++ b/apple/whistlegraph/Sources/StoryVoice.swift @@ -9,7 +9,8 @@ struct StoryAudio { @MainActor enum StoryVoice { static let cache = StoryCache(name: "StoryVoice", limit: 32_000_000) - private static var pending: [String: Task] = [:] + private static var erasureGeneration = 0 + private static var pending: [String: (id: UUID, task: Task)] = [:] static func audio(for row: PieceRevision) async throws -> StoryAudio? { if let id = row.recordingID, let url = UtteranceRecording.url(id), FileManager.default.fileExists(atPath: url.path), let trim = try? RecordingTrim.read(url) { @@ -25,16 +26,32 @@ struct StoryAudio { return StoryAudio(url: url, start: 0, end: try AVAudioPlayer(contentsOf: url).duration, original: false) } + static func cancelCloudRequests() { + for work in pending.values { work.task.cancel() } + pending.removeAll() + } + static func erasePendingWork() { + erasureGeneration += 1 + cancelCloudRequests() + DeviceStorySpeech.cancelAll() + } static func rendered(_ text: String) async throws -> URL { + let generation = erasureGeneration + guard AIConsent.shared.cloudNarration else { return try await DeviceStorySpeech.render(text) } let key = StoryCache.key(["jeffrey-pvc-v1", text]) if let url = cache.find(key, ext: "mp3") { return url } - if let task = pending[key] { return try await task.value } + if let work = pending[key] { return try await work.task.value } + let requestID = UUID() let task = Task { + try Task.checkCancellation() + guard generation == erasureGeneration, AIConsent.shared.cloudNarration else { throw VoiceError.unavailable } var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/say")!) request.httpMethod = "POST"; request.timeoutInterval = 20 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: ["from": text, "provider": "jeffrey", "voice": "neutral:0", "speed": 1]) let (data, response) = try await URLSession.shared.data(for: request) + try Task.checkCancellation() + guard generation == erasureGeneration, AIConsent.shared.cloudNarration else { throw CancellationError() } guard let response = response as? HTTPURLResponse, response.statusCode == 200, data.count > 0, data.count < 8_000_000 else { throw VoiceError.unavailable } let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".mp3") defer { try? FileManager.default.removeItem(at: temporary) } @@ -42,10 +59,11 @@ struct StoryAudio { guard try AVAudioPlayer(contentsOf: temporary).duration > 0 else { throw VoiceError.unavailable } return try cache.store(temporary, key: key, ext: "mp3") } - pending[key] = task - defer { pending[key] = nil } + pending[key] = (requestID, task) + defer { if pending[key]?.id == requestID { pending[key] = nil } } do { return try await task.value } catch { + guard generation == erasureGeneration, !Task.isCancelled else { throw CancellationError() } // Offline exports remain usable. Never cache the fallback as Jeffrey: // reconnecting must retry the requested voice. return try await DeviceStorySpeech.render(text) @@ -55,14 +73,27 @@ struct StoryAudio { } @MainActor private final class DeviceStorySpeech { + private static var active: [UUID: DeviceStorySpeech] = [:] + private let identifier = UUID() private let voice = AVSpeechSynthesizer() + static func cancelAll() { + for speech in Array(active.values) { speech.cancel() } + } + private func cancel() { + timeout?.cancel(); voice.stopSpeaking(at: .immediate); file = nil + let pending = continuation; continuation = nil + pending?.resume(throwing: CancellationError()) + try? FileManager.default.removeItem(at: url) + } private var file: AVAudioFile? private var continuation: CheckedContinuation? private var timeout: Task? private let url = FileManager.default.temporaryDirectory.appendingPathComponent("story-voice-\(UUID()).caf") static func render(_ text: String) async throws -> URL { try await DeviceStorySpeech().render(text) } private func render(_ text: String) async throws -> URL { - try await withCheckedThrowingContinuation { continuation in + Self.active[identifier] = self + defer { Self.active[identifier] = nil } + return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation timeout = Task { try? await Task.sleep(for: .seconds(15)) diff --git a/apple/whistlegraph/Sources/TezosBraincells.swift b/apple/whistlegraph/Sources/TezosBraincells.swift index 64cbb4849c..efec596f96 100644 --- a/apple/whistlegraph/Sources/TezosBraincells.swift +++ b/apple/whistlegraph/Sources/TezosBraincells.swift @@ -1,5 +1,5 @@ +#if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG import SwiftUI -import StoreKit /// A checkout capability can only buy credit for its already-bound AC account. /// The app never handles a wallet key or treats a wallet callback as payment. @@ -17,12 +17,7 @@ import StoreKit let error: String? } func prepare() async { - #if DEBUG available = true - #else - // External digital-goods checkout links are available in the US store. - available = await Storefront.current?.countryCode == "USA" - #endif } private var pending: Pending? { guard let data = UserDefaults.standard.data(forKey: storageKey) else { return nil } @@ -84,3 +79,5 @@ import StoreKit } catch { notice = error.localizedDescription } } } + +#endif diff --git a/apple/whistlegraph/Sources/UtteranceRecording.swift b/apple/whistlegraph/Sources/UtteranceRecording.swift index b6b8809070..d1fda48dca 100644 --- a/apple/whistlegraph/Sources/UtteranceRecording.swift +++ b/apple/whistlegraph/Sources/UtteranceRecording.swift @@ -1,7 +1,7 @@ import Foundation import AVFoundation -// Original microphone samples stay in Application Support, outside model requests. +// Microphone samples stay in Application Support; cloud transcription requires separate consent. // Called only on MusicalInput's serial queue; finish closes the file before playback. final class UtteranceRecording { let id = UUID().uuidString diff --git a/apple/whistlegraph/Sources/WhistlegraphAccount.swift b/apple/whistlegraph/Sources/WhistlegraphAccount.swift index c868ac77ac..a5efdadaff 100644 --- a/apple/whistlegraph/Sources/WhistlegraphAccount.swift +++ b/apple/whistlegraph/Sources/WhistlegraphAccount.swift @@ -7,12 +7,14 @@ import UIKit @MainActor final class WhistlegraphAccount: NSObject, WKNavigationDelegate, UIAdaptivePresentationControllerDelegate { private let key: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "computer.aesthetic.walkieware", kSecAttrAccount as String: "ac"] + private(set) var generation = 0 private var attempt: NativeSignIn? private var completion: ((Result) -> Void)? private var controller: UIViewController? private var exchange: Task? 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, @@ -21,12 +23,26 @@ import UIKit if tokens.expiresAt.timeIntervalSinceNow < 60 { guard let refresh = tokens.refreshToken else { return nil } tokens = try await NativeSignIn.refresh(refresh) + guard generation == expectedGeneration else { return nil } try save(tokens) } 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? { + 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 } + return subject + } /// Forgets the stored sign-in. The thread history stays on the device. func signOut() { + generation += 1 SecItemDelete(key as CFDictionary) } private func save(_ tokens: NativeSignIn.Tokens) throws { @@ -71,7 +87,7 @@ import UIKit exchange = Task { do { let tokens = try await NativeSignIn.exchange(body) - try Task.checkCancellation(); try save(tokens) + try Task.checkCancellation(); generation += 1; 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 b8f3eaef46..b66e166262 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -34,17 +34,30 @@ struct WhistlegraphApp: App { }.padding(30).foregroundStyle(.primary) } } + .task { await voice.braincells.start(session: voice) } + .onChange(of: voice.snapshot.handle) { _, _ in Task { await voice.syncAIAccount(); await voice.braincells.accountChanged() } } + .sheet(isPresented: $voice.showingAIConsent) { NavigationStack { WhistlegraphPrivacySheet(session: voice) } } .preferredColorScheme(appearance == "light" ? .light : appearance == "dark" ? .dark : nil) .onChange(of: phase) { _, value in if value == .background { voice.cancelHold() } if value == .active && voice.capturePhase == .idle { voice.resumePieceAudio() } - if value == .active { Task { await TezDisplayRate.shared.refresh(); await voice.tezosBraincells.refresh(session: voice) } } + if value == .active { + Task { + await TezDisplayRate.shared.refresh() + await voice.braincells.recover() + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG + await voice.tezosBraincells.refresh(session: voice) + #endif + } + } } + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG .onOpenURL { url in if url.scheme == "whistlegraph" && url.host == "braincells" { Task { await voice.tezosBraincells.refresh(session: voice) } } } + #endif } } } @@ -55,6 +68,9 @@ struct Workspace: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.userContentController.addUserScript(WKUserScript(source: "window.__walkiewareNativeShell = 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)) + } config.userContentController.add(context.coordinator, name: "walkie") config.setURLSchemeHandler(WhistlegraphBundle(), forURLScheme: "walkieware") // Custom-scheme fetch responses have status 0 on device. Seed the @@ -186,6 +202,9 @@ struct Workspace: UIViewRepresentable { final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate { @Published var layout = NativeLayout() @Published var snapshot = PieceSnapshot() + @Published var showingAIConsent = false + @Published private(set) var localDataRevision = 0 + let aiConsent = AIConsent.shared @Published var engineReady = false private var performanceTurn = false @Published var performanceCapture = false @@ -231,6 +250,9 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand func command(_ action: String, version: Int? = nil, text: String? = nil, piece: String? = nil) { guard ["checkout", "newPiece", "openPiece", "stop", "signIn", "ask", "retry", "presentVersion", "endPresentation", "setModel", "refreshBraincells"].contains(action) else { return } + if ["ask", "retry"].contains(action), !snapshot.handle.isEmpty, !aiConsent.creation { + showingAIConsent = true; return + } if action == "newPiece" || action == "openPiece" { guard engineReady, !snapshot.busy, capturePhase == .idle else { return } if action == "openPiece" { guard let piece, pieces.contains(where: { $0.id == piece && !$0.current }) else { return } } @@ -261,6 +283,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } func beginHold() { guard engineReady, !snapshot.busy, capturePhase == .idle else { return } + guard aiConsent.creation else { showingAIConsent = true; return } performanceTurn = false captureError = nil; transcript = ""; speechStartedAt = nil; capturePhase = .opening webView?.evaluateJavaScript("voiceStart()") @@ -271,10 +294,59 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand webView?.evaluateJavaScript("window.walkiewareLatchPerformance?.()") } func endHold() { webView?.evaluateJavaScript("voiceEnd()") } + func syncAIAccount() async { + let expected = account.generation + let subject = try? await account.subject() + guard expected == account.generation else { return } + aiConsent.bind(subject: subject ?? nil, handle: snapshot.handle) + } + private func applyAIConsent() { + if !aiConsent.creation { command("stop"); cancelHold() } + if !aiConsent.cloudSpeech { cancelHold() } + if !aiConsent.cloudNarration { StoryVoice.cancelCloudRequests() } + let value = aiConsent.bridge + Task { _ = try? await webView?.callAsyncJavaScript("window.__whistlegraphAIConsent = value; window.walkiewareSetAIConsent?.(value);", arguments: ["value": value], in: nil, contentWorld: .page) } + } + func eraseDeletedAccountLocally() async throws { + command("stop"); cancelHold(); storyPreview.stop(); tv.disconnect() + localDataRevision += 1 + aiConsent.forget(); StoryVoice.erasePendingWork() + account.signOut() + _ = try? await webView?.callAsyncJavaScript("window.walkiewareForgetLocalData?.();", arguments: [:], in: nil, contentWorld: .page) + webView?.stopLoading() + await WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: .distantPast) + let manager = FileManager.default + let support = manager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let caches = manager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let documents = manager.urls(for: .documentDirectory, in: .userDomainMask)[0] + var cleanupError: Error? + for url in [support.appendingPathComponent("Utterances"), support.appendingPathComponent("whistlegraph-drawing-draft.json"), + caches.appendingPathComponent("StoryVoice"), caches.appendingPathComponent("StoryMovies")] { + if manager.fileExists(atPath: url.path) { do { try manager.removeItem(at: url) } catch { cleanupError = error } } + } + // Documents contains app-created local exports and test artifacts only. + do { + for url in try manager.contentsOfDirectory(at: documents, includingPropertiesForKeys: nil) { + do { try manager.removeItem(at: url) } catch { cleanupError = error } + } + } catch { cleanupError = error } + do { + for url in try manager.contentsOfDirectory(at: manager.temporaryDirectory, includingPropertiesForKeys: nil) + where ["story-voice-", "story-canvas-", "Whistlegraph-"].contains(where: { url.lastPathComponent.hasPrefix($0) }) { + do { try manager.removeItem(at: url) } catch { cleanupError = error } + } + } catch { cleanupError = error } + if let name = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: name) } + drawing.clear(); drawing.enabled = false + snapshot = PieceSnapshot(); pieces = []; previewSource = ""; engineReady = false + reloadWorkspace() + if let cleanupError { throw cleanupError } + } /// Drops the Keychain sign-in and tells the engine, which parks its sockets. func signOut() { guard capturePhase == .idle else { return } account.signOut() + aiConsent.bind(subject: nil, handle: "") emitEngine(["kind": "account", "token": ""]) } func cancelHold() { performanceCapture = false; webView?.evaluateJavaScript("voiceEnd(true)"); cancel() } @@ -317,16 +389,23 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand else { startupFailure = "The workspace stopped. Tap Reload to reopen it." } } let account = WhistlegraphAccount() + let braincells = WhistlegraphBraincells() + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG let tezosBraincells = TezosBraincells() + #endif // The microphone, recognizer and release timing live in SpeechCapture; this // class only turns its events into screen state and bridge messages. private let capture = SpeechCapture() override init() { super.init() + aiConsent.changed = { [weak self] in self?.applyAIConsent() } capture.hasVisualInput = { [weak self] in self?.drawing.hasInk == true } capture.speechToken = { [weak self] in - guard let self, self.snapshot.handle == "jeffrey" else { return nil } - return try await self.account.token() + guard let self, self.snapshot.handle == "jeffrey", self.aiConsent.cloudSpeech else { return nil } + let generation = self.account.generation + let token = try await self.account.token() + guard !Task.isCancelled, generation == self.account.generation, self.aiConsent.cloudSpeech else { return nil } + return token } capture.onEvent = { [weak self] kind, text, id in self?.emit(kind, text: text, id: id) } capture.onLevel = { [weak self] rms in guard let self else { return }; self.microphoneLevels = Array(self.microphoneLevels.dropFirst()) + [rms] } @@ -348,6 +427,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand private var visualCaptureTask: Task? private var previewThreadID = UUID().uuidString + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG func mintCapture() async throws -> (hash: String, png: String) { guard let webView, !snapshot.busy, !presentingStory, !previewSource.isEmpty, paintedPreviewHash == VisualCapture.hash(previewSource) else { @@ -363,11 +443,10 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand guard let png = frames.first?["png"] as? String else { throw NativeSignIn.failure("Could not capture the artwork cover.") } return (hash, png) } + #endif func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if !message.frameInfo.isMainFrame, - message.frameInfo.request.url?.host == "aesthetic.computer", - message.frameInfo.request.url?.scheme == "https", + if PreviewNavigation.bridge(message.frameInfo.request.url, mainFrame: message.frameInfo.isMainFrame, document: .workspace) == .artwork, let body = message.body as? [String: Any] { #if DEBUG if NativeScreenFixture.mode == "audio", body["action"] as? String == "audioProbe" { @@ -406,9 +485,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } return } - guard message.frameInfo.isMainFrame, - message.frameInfo.request.url?.scheme == "walkieware", - message.frameInfo.request.url?.host == "app", + guard PreviewNavigation.bridge(message.frameInfo.request.url, mainFrame: message.frameInfo.isMainFrame, document: .workspace) == .workspace, let body = message.body as? [String: Any], let action = body["action"] as? String, let id = body["id"] as? String, id.count <= 100 else { return } @@ -499,6 +576,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand case "account": Task { do { emitEngine(["kind": "account", "token": try await account.token() ?? ""]) } catch { emitEngine(["kind": "account", "token": ""]) } } + case "aiConsent": showingAIConsent = true case "signIn": account.signIn(from: webView) { [weak self] result in switch result { diff --git a/apple/whistlegraph/Sources/WhistlegraphBraincells.swift b/apple/whistlegraph/Sources/WhistlegraphBraincells.swift new file mode 100644 index 0000000000..8b1dd2f1ba --- /dev/null +++ b/apple/whistlegraph/Sources/WhistlegraphBraincells.swift @@ -0,0 +1,147 @@ +import SwiftUI +import StoreKit + +@MainActor final class WhistlegraphBraincells: ObservableObject { + @Published private(set) var product: Product? + @Published private(set) var busy = false + @Published private(set) var notice = "" + @Published private(set) var storeStatus = "Loading the App Store…" + private let endpoint = URL(string: "https://aesthetic.computer/api/whistlegraph-iap")! + private weak var session: WhistlegraphSession? + private let delivery = StoreCreditDelivery() + private var listener: Task? + private var recovering = false + private var loading = false + + deinit { listener?.cancel() } + + func start(session: WhistlegraphSession) async { + self.session = session + if listener == nil { + listener = Task { [weak self] in + for await result in StoreKit.Transaction.updates { + guard !Task.isCancelled else { break } + await self?.settle(result) + } + } + } + async let catalog: Void = load() + await recover() + await catalog + } + + func load() async { + guard !loading else { return } + 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." : "" + } catch { + product = nil + storeStatus = "Could not load App Store purchases. Try again." + } + } + + func recover() async { + guard !recovering else { return } + recovering = true; defer { recovering = false } + for await result in StoreKit.Transaction.unfinished { await settle(result) } + } + + func accountChanged() async { + notice = "" + await recover() + } + + private func credential() async throws -> StoreCreditDelivery.Credential? { + guard let session else { return nil } + let generation = session.account.generation + guard let bearer = try await session.account.token(), generation == session.account.generation else { return nil } + return .init(bearer: bearer, generation: generation) + } + private func isCurrent(_ credential: StoreCreditDelivery.Credential) -> Bool { + session?.account.generation == credential.generation + } + private func request(_ action: StoreCreditDelivery.Action, _ credential: StoreCreditDelivery.Credential) async throws -> Data { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST"; request.timeoutInterval = 30 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(credential.bearer)", forHTTPHeaderField: "Authorization") + let body: [String: String] + switch action { + case .account: body = ["action": "account"] + case .redeem(let jws): body = ["action": "redeem", "jws": jws] + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await URLSession.shared.data(for: request) + 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.") + } + return data + } + + func buy() async { + guard !busy, let product else { return } + busy = true; notice = ""; defer { busy = false } + do { + guard let auth = try await credential(), isCurrent(auth) else { + notice = "Sign in to AC before buying braincells."; return + } + struct Account: Decodable { let appAccountToken: UUID } + let account = try JSONDecoder().decode(Account.self, from: await request(.account, auth)) + 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." + @unknown default: notice = "The App Store has not completed this purchase." + } + } catch { notice = error.localizedDescription } + } + + private func settle(_ result: VerificationResult) async { + guard case .verified(let transaction) = result else { + 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, + accountToken: transaction.appAccountToken, environment: transaction.environment.rawValue, + revoked: transaction.revocationDate != nil, jws: result.jwsRepresentation) + let outcome = await delivery.deliver(receipt, + 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 + } + } +} + +struct BraincellPurchase: View { + @ObservedObject var purchase: WhistlegraphBraincells + let signedIn: Bool + var body: some View { + if let product = purchase.product { + Button { + Task { await purchase.buy() } + } label: { + HStack { + Text("1,000,000 braincells") + Spacer() + if purchase.busy { ProgressView() } else { Text(product.displayPrice) } + } + }.disabled(purchase.busy || !signedIn).accessibilityIdentifier("brain-buy-app-store") + Text("For AC inference. Purchased braincells do not expire.").font(.footnote).foregroundStyle(.secondary) + } else { + Text(purchase.storeStatus).font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("brain-app-store-status") + Button("Reload App Store purchases") { Task { await purchase.load() } } + } + if !purchase.notice.isEmpty { Text(purchase.notice).font(.footnote).accessibilityIdentifier("brain-purchase-notice") } + Button("Check pending purchases") { Task { await purchase.recover() } }.disabled(purchase.busy) + if !signedIn { Text("Sign in to AC to buy braincells.").font(.footnote).foregroundStyle(.secondary) } + } +} diff --git a/apple/whistlegraph/Sources/WhistlegraphDeleteAccount.swift b/apple/whistlegraph/Sources/WhistlegraphDeleteAccount.swift new file mode 100644 index 0000000000..569abe1263 --- /dev/null +++ b/apple/whistlegraph/Sources/WhistlegraphDeleteAccount.swift @@ -0,0 +1,66 @@ +import SwiftUI +import WebKit + +extension WhistlegraphSession { + func deletionClient() -> AccountDeletionClient { + AccountDeletionClient(credential: { [weak self] in + guard let self else { return nil } + let generation = self.account.generation + guard let token = try await self.account.token(), generation == self.account.generation else { return nil } + return .init(bearer: token, generation: generation) + }, current: { [weak self] in self?.account.generation == $0.generation }, clear: { [weak self] in + guard let self else { return } + try await self.eraseDeletedAccountLocally() + }) + } +} + +struct WhistlegraphDeleteAccountSheet: View { + @ObservedObject var session: WhistlegraphSession + @State private var client: AccountDeletionClient? + @State private var preview: AccountDeletionClient.Preview? + @State private var schedule: AccountDeletionClient.Schedule? + @State private var busy = false + @State private var notice = "" + @State private var confirming = false + var body: some View { + List { + if let schedule { + Section { + Text("Account deletion scheduled").font(.headline) + Text("Your account is locked. Permanent deletion is scheduled after \(schedule.purgeDate?.formatted(date: .long, time: .shortened) ?? schedule.purgeAfter).") + if let error = schedule.localErasureError { Text(error) } + else { Text("This phone’s app data has been erased.") } + if schedule.mailed == true { Text("Check your email for the recovery link during the grace period.") } + } + } else if let preview { + Section { + Text("Delete @\(preview.handle) across Aesthetic Computer?").font(.headline) + Text("This locks your AC account now and schedules permanent deletion after \(preview.graceDays) days. It includes your private Whistlegraph drafts, source, history, and AC uploads.") + LabeledContent("Purchased braincells to lose", value: preview.braincells.formatted(.number.precision(.fractionLength(0)))) + if let count = preview.counts["whistlegraphs"] { LabeledContent("Private Whistlegraphs", value: count.formatted()) } + Text("It also erases all saved Whistlegraph work, source drafts, recordings and story caches on this phone. Export anything you want to keep first. Files already saved to Photos or shared elsewhere remain there.") + Text("Public blockchain records and independently pinned IPFS artwork cannot be erased. Records that must be retained for legal or transaction integrity purposes are handled as described in the privacy policy.") + Button("Delete my AC account", role: .destructive) { confirming = true } + .disabled(busy).accessibilityIdentifier("account-delete-confirm") + } + } else if busy { ProgressView("Reading the account…") } + if !notice.isEmpty { Text(notice).accessibilityIdentifier("account-delete-notice") } + if !busy && preview == nil && schedule == nil { Button("Read deletion preview") { Task { await load() } } } + } + .navigationTitle("Delete account").navigationBarTitleDisplayMode(.inline) + .task { if client == nil { client = session.deletionClient(); await load() } } + .confirmationDialog("Delete this AC account and this phone's saved Whistlegraphs?", isPresented: $confirming, titleVisibility: .visible) { + Button("Delete account", role: .destructive) { Task { await confirm() } } + Button("Keep account", role: .cancel) {} + } + } + private func load() async { + busy = true; notice = ""; defer { busy = false } + do { preview = try await client?.load() } catch { notice = error.localizedDescription } + } + private func confirm() async { + busy = true; notice = ""; defer { busy = false } + do { schedule = try await client?.confirm() } catch { notice = error.localizedDescription } + } +} diff --git a/apple/whistlegraph/Sources/WhistlegraphHeader.swift b/apple/whistlegraph/Sources/WhistlegraphHeader.swift index 507923e116..26fcaaff29 100644 --- a/apple/whistlegraph/Sources/WhistlegraphHeader.swift +++ b/apple/whistlegraph/Sources/WhistlegraphHeader.swift @@ -40,15 +40,15 @@ struct PiecesSheet: View { if let inference { Section { LabeledContent(inference.label, value: inference.provider) } } + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG if let session = mintSession, session.snapshot.handle == "jeffrey", session.snapshot.hasPiece { - #if DEBUG Section { NavigationLink { WhistlegraphMintSheet(session: session) } label: { Label("Mint on HEN", systemImage: "seal") }.disabled(disabled).accessibilityIdentifier("pieces-mint") } - #endif } + #endif Section(pieces.count == 1 ? "Your piece" : "Your pieces") { ForEach(pieces) { piece in Button { @@ -92,6 +92,7 @@ struct AccountSheet: View { @Binding var appearance: String let signIn: () -> Void let signOut: () -> Void + var session: WhistlegraphSession? = nil @Environment(\.dismiss) private var dismiss @State private var confirmingSignOut = false @AppStorage(ButtonSounds.settingKey) private var sounds = true @@ -120,11 +121,18 @@ struct AccountSheet: View { Toggle("Interface sounds", isOn: $sounds).accessibilityIdentifier("account-sounds") .onChange(of: sounds) { _, on in if on { ButtonSounds.play(.tick) } } } footer: { Text("Keys and buttons respect silent mode. Haptics stay on.") } + if let session { + Section { + 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") + } + } if !handle.isEmpty { Section { Button(role: .destructive) { confirmingSignOut = true } label: { Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") } .accessibilityIdentifier("account-sign-out") - } footer: { Text("Your pieces stay on this phone. Making new versions needs a signed-in handle.") } + } footer: { Text("Your pieces stay on this phone. AI-generated versions need a signed-in handle; local source edits do not.") } } } .navigationTitle(handle.isEmpty ? "Account" : "@" + handle) @@ -134,7 +142,7 @@ struct AccountSheet: View { Button("Sign out", role: .destructive) { ButtonSounds.play(.stop); signOut(); dismiss() } } } - .presentationDetents([.medium]) + .presentationDetents([.medium, .large]) } } @@ -174,7 +182,7 @@ struct IdentityHeader: View { } .sheet(isPresented: $showingAccount) { AccountSheet(handle: session.snapshot.handle, colors: session.snapshot.colors, appearance: $appearance, - signIn: { session.command("signIn") }, signOut: { session.signOut() }) + signIn: { session.command("signIn") }, signOut: { session.signOut() }, session: session) } } } diff --git a/apple/whistlegraph/Sources/WhistlegraphMint.swift b/apple/whistlegraph/Sources/WhistlegraphMint.swift index a288374efd..27f16dcdd7 100644 --- a/apple/whistlegraph/Sources/WhistlegraphMint.swift +++ b/apple/whistlegraph/Sources/WhistlegraphMint.swift @@ -1,3 +1,4 @@ +#if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG import SwiftUI import SafariServices @@ -134,3 +135,5 @@ struct WhistlegraphMintSheet: View { } catch { notice = error.localizedDescription } } } + +#endif diff --git a/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift b/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift new file mode 100644 index 0000000000..152f09d55c --- /dev/null +++ b/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift @@ -0,0 +1,65 @@ +import SwiftUI + +@MainActor final class AIConsent: ObservableObject { + static let shared = AIConsent() + @Published private(set) var record = AIConsentRecord() + @Published private(set) var handle = "" + private var subject: String? + var changed: () -> Void = {} + var signedIn: Bool { subject != nil && !handle.isEmpty } + var creation: Bool { signedIn && record.creation } + var cloudSpeech: Bool { signedIn && record.cloudSpeech } + var cloudNarration: Bool { signedIn && record.cloudNarration } + var bridge: [String: Any] { ["handle": handle, "creation": creation] } + func bind(subject: String?, handle: String) { + if self.subject != subject { StoryVoice.cancelCloudRequests() } + self.subject = subject; self.handle = handle + record = AIConsentRecord.read(subject: subject) + changed() + } + func set(_ key: WritableKeyPath, _ value: Bool) { + guard let subject, signedIn else { return } + record[keyPath: key] = value; record.updatedAt = Date() + record.save(subject: subject); changed() + } + func forget() { + if let subject { UserDefaults.standard.removeObject(forKey: AIConsentRecord.key(subject: subject)) } + self.subject = nil; handle = ""; record = AIConsentRecord(); changed() + } +} + +struct WhistlegraphPrivacySheet: View { + @ObservedObject var session: WhistlegraphSession + @ObservedObject private var consent = AIConsent.shared + @Environment(\.dismiss) private var dismiss + private let privacy = URL(string: "https://aesthetic.computer/privacy-policy.html")! + var body: some View { + List { + Section("AI creation") { + Text("AC sends your prompts, speech transcripts, selected source and version context, drawings, requested sound measurements, and cropped artwork preview images to AI services to generate and check edits.") + Text("Hosted models use OpenRouter and the model provider you select: Anthropic, OpenAI, DeepSeek, Moonshot AI, Alibaba/Qwen, MiniMax, or Z.ai. Personal models use Anthropic or OpenAI. The Brain panel identifies the current model and service.") + Toggle("Allow AI creation", isOn: Binding(get: { consent.creation }, set: { consent.set(\.creation, $0) })) + .disabled(!consent.signedIn).accessibilityIdentifier("privacy-ai-creation") + } + Section("Cloud speech") { + Text("Optional OpenAI transcription sends microphone audio through AC or directly to OpenAI during recording and for word timing. It currently requires an eligible personal account. With this off, speech recognition stays on the device.") + Toggle("Allow audio to OpenAI", isOn: Binding(get: { consent.cloudSpeech }, set: { consent.set(\.cloudSpeech, $0) })) + .disabled(!consent.signedIn).accessibilityIdentifier("privacy-cloud-speech") + } + Section("Cloud narration") { + Text("Optional Jeffrey narration sends story captions to ElevenLabs through AC. With this off, stories use your saved recording or device speech.") + Toggle("Allow captions to ElevenLabs", isOn: Binding(get: { consent.cloudNarration }, set: { consent.set(\.cloudNarration, $0) })) + .disabled(!consent.signedIn).accessibilityIdentifier("privacy-cloud-narration") + } + Section { + Text("Turning permission off stops new requests and cancels active sending. Data already sent may remain with those services under their policies. Viewing, editing source and exporting your saved work remain available.") + Text("Your AC account privately stores source, version history, requests and diagnostic receipts. Saved microphone recordings remain on this phone unless cloud speech is enabled. Avoid sending sensitive personal information.") + Link("Privacy policy", destination: privacy).accessibilityIdentifier("privacy-policy") + Link("Contact support", destination: URL(string: "mailto:mail@aesthetic.computer")!) + if !consent.signedIn { Text("Sign in to manage this account's AI permissions.").foregroundStyle(.secondary) } + } + } + .navigationTitle("AI & privacy").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + } +} diff --git a/apple/whistlegraph/Sources/WhistlegraphScreen.swift b/apple/whistlegraph/Sources/WhistlegraphScreen.swift index 749fced3a7..dd70ea41f3 100644 --- a/apple/whistlegraph/Sources/WhistlegraphScreen.swift +++ b/apple/whistlegraph/Sources/WhistlegraphScreen.swift @@ -213,6 +213,7 @@ struct WhistlegraphScreen: View { 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 + guard session.aiConsent.creation || session.snapshot.handle.isEmpty else { session.showingAIConsent = true; return } ButtonSounds.play(.sent); session.command("ask", text: text); showComposer = false } } else { @@ -248,6 +249,7 @@ struct WhistlegraphScreen: View { .statusBarHidden(narrator.isPlaying) .sheet(isPresented: $showTV) { WhistlegraphTVSheet(tv: session.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) } .onChange(of: exporter.movie?.id) { _, value in if value != nil { narrator.setPaused(true) } } .onChange(of: scenePhase) { _, value in if value == .background { exporter.cancel(); narrator.stop() } else if value == .inactive { narrator.setPaused(true) } } diff --git a/apple/whistlegraph/Sources/WhistlegraphSource.swift b/apple/whistlegraph/Sources/WhistlegraphSource.swift new file mode 100644 index 0000000000..e4c18b2cea --- /dev/null +++ b/apple/whistlegraph/Sources/WhistlegraphSource.swift @@ -0,0 +1,154 @@ +import SwiftUI +import WebKit + +struct WhistlegraphSourceDocument: Decodable { + let piece: String + let code: String + let version: Int + let source: String + let sourceHash: String + // Include the source hash so a draft can never replace a different revision. + var draftKey: String { "whistlegraph-source-draft:\(piece):\(version):\(sourceHash)" } +} + +extension WhistlegraphSession { + func sourceDocument() async throws -> WhistlegraphSourceDocument { + try await sourceOperation("read", arguments: [:]) + } + + func applySource(_ source: String, to document: WhistlegraphSourceDocument) async throws -> WhistlegraphSourceDocument { + guard !snapshot.busy, capturePhase == .idle else { + throw sourceFailure("Finish the current request or recording before editing source.") + } + return try await sourceOperation("apply", arguments: ["piece": document.piece, "version": document.version, + "sourceHash": document.sourceHash, "source": source]) + } + + private func sourceFailure(_ message: String) -> NSError { + NSError(domain: "WhistlegraphSource", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + + private func sourceOperation(_ action: String, arguments: [String: Any]) async throws -> WhistlegraphSourceDocument { + guard engineReady, let webView else { throw sourceFailure("The piece is still loading.") } + // Source travels as a structured argument, never interpolated JavaScript. + let value = try await webView.callAsyncJavaScript(""" + try { + const editor = window.walkiewareSourceEditor; + if (!editor) throw Error('Source is not ready.'); + return {document: await (action === 'read' ? editor.read() : editor.apply(request))}; + } catch (error) { return {error: error.message || 'Could not edit source.'}; } + """, arguments: ["action": action, "request": arguments], in: nil, contentWorld: .page) + guard let response = value as? [String: Any] else { throw sourceFailure("Could not read source.") } + if let error = response["error"] as? String { throw sourceFailure(error) } + guard let document = response["document"] as? [String: Any] else { throw sourceFailure("Could not read source.") } + return try JSONDecoder().decode(WhistlegraphSourceDocument.self, from: JSONSerialization.data(withJSONObject: document)) + } +} + +// Present inside a NavigationStack. The complete source stays selectable and +// editable, independently of the abbreviated streaming ticker on the workspace. +struct WhistlegraphSourceSheet: View { + @ObservedObject var session: WhistlegraphSession + @State private var document: WhistlegraphSourceDocument? + @State private var draft = "" + @State private var working = false + @State private var notice = "" + @State private var showingReset = false + @FocusState private var editing: Bool + private var changed: Bool { document.map { draft != $0.source } ?? false } + + var body: some View { + VStack(spacing: 12) { + if let document { + HStack { + Text("Version \(document.version)").font(.headline) + Spacer() + Text(changed ? "Draft saved on this phone" : "Saved source").font(.footnote).foregroundStyle(.secondary) + } + TextEditor(text: $draft) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never).autocorrectionDisabled(true) + .focused($editing).disabled(working) + .accessibilityLabel("Complete piece source").accessibilityIdentifier("source-editor") + .overlay(RoundedRectangle(cornerRadius: 8).stroke(.secondary.opacity(0.3))) + if !notice.isEmpty { + Text(notice).font(.callout).frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("source-notice") + } + Button { + editing = false + Task { await apply() } + } label: { + HStack { + if working { ProgressView() } + Text(working ? "Checking preview…" : "Apply local edit") + }.frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(working || !changed || session.snapshot.busy || session.capturePhase != .idle) + .accessibilityIdentifier("source-apply") + Text("Checks the code and preview, then saves a new version. Uses no AI or braincells.") + .font(.footnote).foregroundStyle(.secondary) + } else if working { + ProgressView("Loading source…").frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView { + Label("Source unavailable", systemImage: "curlybraces") + } description: { Text(notice) } actions: { Button("Retry") { Task { await load() } } } + } + } + .padding() + .navigationTitle("Source").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if document != nil { + Button { UIPasteboard.general.string = draft; notice = "Source copied." } label: { + Image(systemName: "doc.on.doc") + }.accessibilityLabel("Copy complete source").accessibilityIdentifier("source-copy") + ShareLink(item: draft) { Image(systemName: "square.and.arrow.up") } + .accessibilityLabel("Share complete source").accessibilityIdentifier("source-share") + Button { showingReset = true } label: { Image(systemName: "arrow.counterclockwise") } + .disabled(!changed || working).accessibilityLabel("Discard source draft") + } + } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { editing = false } + } + } + .confirmationDialog("Discard this source draft?", isPresented: $showingReset, titleVisibility: .visible) { + Button("Discard draft", role: .destructive) { + if let document { draft = document.source; UserDefaults.standard.removeObject(forKey: document.draftKey); notice = "" } + } + } + .onChange(of: draft) { _, source in + guard let document else { return } + if source == document.source { UserDefaults.standard.removeObject(forKey: document.draftKey) } + else { UserDefaults.standard.set(source, forKey: document.draftKey) } + } + .task { if document == nil { await load() } } + } + + @MainActor private func load() async { + working = true + defer { working = false } + do { + let value = try await session.sourceDocument() + document = value + draft = UserDefaults.standard.string(forKey: value.draftKey) ?? value.source + notice = "" + } catch { notice = error.localizedDescription } + } + + @MainActor private func apply() async { + guard let original = document else { return } + working = true; notice = "" + defer { working = false } + do { + let saved = try await session.applySource(draft, to: original) + UserDefaults.standard.removeObject(forKey: original.draftKey) + document = saved; draft = saved.source + notice = "Saved version \(saved.version)." + } catch { notice = error.localizedDescription } + } +} diff --git a/apple/whistlegraph/Sources/WorkspaceCoordinator.swift b/apple/whistlegraph/Sources/WorkspaceCoordinator.swift index 97185b25d7..c2ea125828 100644 --- a/apple/whistlegraph/Sources/WorkspaceCoordinator.swift +++ b/apple/whistlegraph/Sources/WorkspaceCoordinator.swift @@ -9,6 +9,14 @@ final class WorkspaceCoordinator: NSObject, WKScriptMessageHandler, WKNavigation func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { session?.userContentController(controller, didReceive: message) } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(PreviewNavigation.allows(navigationAction.request.url, + mainFrame: navigationAction.targetFrame?.isMainFrame, document: .workspace) ? .allow : .cancel) + } + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + decisionHandler(PreviewNavigation.allows(navigationResponse.response.url, + mainFrame: navigationResponse.isForMainFrame, document: .workspace) ? .allow : .cancel) + } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { session?.webView(webView, didFinish: navigation) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { session?.webView(webView, didFailProvisionalNavigation: navigation, withError: error) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { session?.webView(webView, didFail: navigation, withError: error) } diff --git a/apple/whistlegraph/Tests/AIConsentRecordCheck.swift b/apple/whistlegraph/Tests/AIConsentRecordCheck.swift new file mode 100644 index 0000000000..b73dff6713 --- /dev/null +++ b/apple/whistlegraph/Tests/AIConsentRecordCheck.swift @@ -0,0 +1,25 @@ +import Foundation + +@main struct AIConsentRecordCheck { + static func main() throws { + let name = "whistlegraph-consent-test-" + UUID().uuidString + let defaults = UserDefaults(suiteName: name)! + defer { defaults.removePersistentDomain(forName: name) } + func allOff(_ value: AIConsentRecord) -> Bool { !value.creation && !value.cloudSpeech && !value.cloudNarration } + precondition(allOff(AIConsentRecord.read(subject: nil, defaults: defaults))) + precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults))) + var record = AIConsentRecord(); record.creation = true + record.save(subject: "alice", defaults: defaults) + let restored = AIConsentRecord.read(subject: "alice", defaults: defaults) + precondition(restored.creation && !restored.cloudSpeech && !restored.cloudNarration, "Optional providers stay off") + precondition(allOff(AIConsentRecord.read(subject: "bob", defaults: defaults)), "Consent never transfers to another account") + record.creation = false; record.save(subject: "alice", defaults: defaults) + precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults)), "Revocation persists") + record.creation = true; record.version = AIConsentRecord.version - 1 + record.save(subject: "alice", defaults: defaults) + precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults)), "Changed disclosure needs fresh permission") + defaults.set(Data("invalid".utf8), forKey: AIConsentRecord.key(subject: "alice")) + precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults))) + print("AIConsentRecordCheck passed (7 cases)") + } +} diff --git a/apple/whistlegraph/Tests/AccountDeletionClientCheck.swift b/apple/whistlegraph/Tests/AccountDeletionClientCheck.swift new file mode 100644 index 0000000000..d6b2665d14 --- /dev/null +++ b/apple/whistlegraph/Tests/AccountDeletionClientCheck.swift @@ -0,0 +1,68 @@ +import Foundation + +@main struct AccountDeletionClientCheck { + @MainActor static func main() async throws { + var generation = 1, requests = 0, clears = 0, status = 200 + var signedIn = true, switchDuringGET = false, switchDuringPOST = false, cleanupFails = false + let validPreview = "{\"handle\":\"fixture\",\"email\":\"fixture@example.test\",\"graceDays\":14,\"handleHoldDays\":90,\"braincells\":1000000,\"counts\":{\"whistlegraphs\":3}}" + let validSchedule = "{\"state\":\"scheduled\",\"purgeAfter\":\"2026-10-20T00:00:00.000Z\",\"mailed\":true}" + var postBody = validSchedule + func client() -> AccountDeletionClient { + AccountDeletionClient(credential: { signedIn ? .init(bearer: "mock-token", generation: generation) : nil }, + current: { $0.generation == generation }, send: { request in + requests += 1 + precondition(request.value(forHTTPHeaderField: "Authorization") == "Bearer mock-token") + precondition(request.url?.host == "aesthetic.computer") + let get = request.httpMethod == "GET" + if get { precondition(request.url?.query?.contains("preview") == true) } + else { precondition(request.httpMethod == "POST" && request.httpBody == Data("{}".utf8)) } + if (get && switchDuringGET) || (!get && switchDuringPOST) { generation += 1 } + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + return (Data((get ? validPreview : postBody).utf8), response) + }, clear: { + clears += 1 + if cleanupFails { throw URLError(.cannotRemoveFile) } + }) + } + func rejected(_ run: () async throws -> Void) async { + do { try await run(); preconditionFailure("Expected failure") } catch {} + } + let first = client() + await rejected { _ = try await first.confirm() } + precondition(requests == 0 && clears == 0, "Preview required before destructive request") + signedIn = false + await rejected { _ = try await first.load() } + precondition(requests == 0) + signedIn = true + let preview = try await first.load() + precondition(preview.counts["whistlegraphs"] == 3 && preview.braincells == 1_000_000 && clears == 0) + generation += 1 + let before = requests + await rejected { _ = try await first.confirm() } + precondition(requests == before && clears == 0, "Switched account requires its own preview") + switchDuringGET = true + await rejected { _ = try await first.load() } + precondition(first.preview == nil) + switchDuringGET = false + _ = try await first.load(); status = 503 + await rejected { _ = try await first.confirm() } + precondition(clears == 0, "Server failure never erases local data") + status = 200; postBody = "{\"state\":\"scheduled\",\"purgeAfter\":\"bad-date\"}" + await rejected { _ = try await first.confirm() } + precondition(clears == 0, "Malformed acknowledgement never erases local data") + postBody = validSchedule; switchDuringPOST = true + await rejected { _ = try await first.confirm() } + precondition(clears == 0, "In-flight account change preserves current account's work") + switchDuringPOST = false + _ = try await first.load() + let result = try await first.confirm() + precondition(result.state == "scheduled" && result.localErasureError == nil && clears == 1) + let after = requests + await rejected { _ = try await first.confirm() } + precondition(requests == after && clears == 1, "Consumed preview cannot schedule twice") + let partial = client(); _ = try await partial.load(); cleanupFails = true + let scheduled = try await partial.confirm() + precondition(scheduled.state == "scheduled" && scheduled.localErasureError != nil, "Partial local cleanup must still report acknowledged server deletion") + print("AccountDeletionClientCheck passed (11 cases; mock requests only)") + } +} diff --git a/apple/whistlegraph/Tests/PreviewNavigationCheck.swift b/apple/whistlegraph/Tests/PreviewNavigationCheck.swift new file mode 100644 index 0000000000..8183c93f7a --- /dev/null +++ b/apple/whistlegraph/Tests/PreviewNavigationCheck.swift @@ -0,0 +1,38 @@ +import Foundation + +@main struct PreviewNavigationCheck { + static func main() { + let runtime = "https://aesthetic.computer/wipe?noauth=true&noplot=true&nogap=true&nolabel=true&preview=walkieware" + 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?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)) + precondition(allowed(runtime) && allowed(runtime, document: .story)) + precondition(allowed("about:blank")) + for value in ["walkieware://app/easel/phone/host.html", "https://aesthetic.computer/braincells/", + "https://aesthetic.computer/mint/#secret", "https://checkout.stripe.com/c/pay", + "https://pay.aesthetic.computer/", "temple://connect", "javascript:alert(1)", + "https://aesthetic.computer.evil.invalid/wipe", "https://evil.invalid/?next=wipe", + "data:text/html,checkout", "walkieware://app/index.html?walkie=1&checkout=1"] { + precondition(!allowed(value) && !allowed(value, main: true) && !allowed(value, main: nil), value) + } + precondition(!allowed(runtime, main: true), "The runtime cannot replace native app's root document") + precondition(!allowed(runtime, main: nil), "New windows are not artwork frames") + precondition(!allowed(runtime + "&noauth=false"), "Duplicate flags cannot bypass preview constraints") + precondition(!allowed(runtime.replacingOccurrences(of: "noauth=true", with: "noauth=false"))) + precondition(!allowed(runtime.replacingOccurrences(of: "https://", with: "http://"))) + precondition(!allowed(runtime.replacingOccurrences(of: "aesthetic.computer/", with: "user@aesthetic.computer/"))) + precondition(!allowed(runtime.replacingOccurrences(of: "aesthetic.computer/", with: "aesthetic.computer:443/"))) + precondition(!allowed("walkieware://app/story.html", main: true)) + precondition(!allowed("walkieware://app/index.html?walkie=1", main: true, document: .story)) + precondition(PreviewNavigation.bridge(URL(string: runtime), mainFrame: false, document: .workspace) == .artwork) + precondition(PreviewNavigation.bridge(URL(string: runtime), mainFrame: true, document: .workspace) == .none) + precondition(PreviewNavigation.bridge(URL(string: "walkieware://app/index.html?walkie=1"), mainFrame: true, document: .workspace) == .workspace) + precondition(PreviewNavigation.bridge(URL(string: "walkieware://app/index.html?walkie=1"), mainFrame: false, document: .workspace) == .none) + precondition(PreviewNavigation.bridge(URL(string: "about:blank"), mainFrame: false, document: .workspace) == .none) + precondition(PreviewNavigation.bridge(URL(string: "https://aesthetic.computer/mint/"), mainFrame: false, document: .workspace) == .none) + print("PASS: preview documents render; checkout, bundled Aesel, redirects, and new-window destinations are denied") + } +} diff --git a/apple/whistlegraph/Tests/StoreCreditDeliveryCheck.swift b/apple/whistlegraph/Tests/StoreCreditDeliveryCheck.swift new file mode 100644 index 0000000000..7ab523dca2 --- /dev/null +++ b/apple/whistlegraph/Tests/StoreCreditDeliveryCheck.swift @@ -0,0 +1,97 @@ +import Foundation + +@main struct StoreCreditDeliveryCheck { + @MainActor static func main() async throws { + let owner = UUID(), other = UUID() + let ledger = StoreCreditDelivery() + var finished = 0, requests = 0, generation = 1 + var account = owner, failNetwork = false, wrongAck = false, wrongCredits = false + var wrongEnvironment = false, credited = true, changeOnAccount = false, changeOnGrant = false + func receipt(product: String = StoreCreditDelivery.productID, token: UUID? = nil, + revoked: Bool = false) -> StoreCreditDelivery.Receipt { + .init(id: "1234", productID: product, accountToken: token ?? owner, + environment: "Sandbox", revoked: revoked, jws: "signed") + } + let request: (StoreCreditDelivery.Action, StoreCreditDelivery.Credential) async throws -> Data = { action, _ in + requests += 1 + if failNetwork { throw URLError(.notConnectedToInternet) } + switch action { + case .account: + if changeOnAccount { generation += 1 } + return try JSONSerialization.data(withJSONObject: ["appAccountToken": account.uuidString]) + case .redeem(let jws): + precondition(jws == "signed") + if changeOnGrant { generation += 1 } + return try JSONSerialization.data(withJSONObject: ["transactionId": wrongAck ? "5678" : "1234", + "credits": wrongCredits ? 0 : StoreCreditDelivery.credits, "credited": credited, + "environment": wrongEnvironment ? "Production" : "Sandbox"]) + } + } + func deliver(_ value: StoreCreditDelivery.Receipt) async -> StoreCreditDelivery.Outcome { + await ledger.deliver(value, credential: { .init(bearer: "access", generation: generation) }, + isCurrent: { $0.generation == generation }, request: request, finish: { finished += 1 }) + } + let first = await deliver(receipt()) + precondition(first == .added && finished == 1 && requests == 2, "Durable matching grant finishes once") + credited = false + let repeatGrant = await deliver(receipt()) + precondition(repeatGrant == .alreadyAdded && finished == 2, "Idempotent server grant can finish recovery") + let before = finished + account = other; requests = 0 + _ = await deliver(receipt()) + precondition(finished == before && requests == 1, "Wrong account never redeems or finishes") + account = owner + failNetwork = true + _ = await deliver(receipt()) + precondition(finished == before, "Offline purchase remains unfinished") + failNetwork = false; wrongAck = true + _ = await deliver(receipt()) + precondition(finished == before, "Another transaction acknowledgement cannot finish this purchase") + wrongAck = false; wrongCredits = true + _ = await deliver(receipt()) + precondition(finished == before, "Incomplete grant cannot finish") + wrongCredits = false; wrongEnvironment = true + _ = await deliver(receipt()) + precondition(finished == before, "Environment mismatch cannot finish") + wrongEnvironment = false; changeOnAccount = true; requests = 0 + _ = await deliver(receipt()) + precondition(finished == before && requests == 1, "Account switch before redemption prevents request") + changeOnAccount = false; changeOnGrant = true + _ = await deliver(receipt()) + precondition(finished == before, "Account switch while grant is in flight preserves retry") + changeOnGrant = false + _ = await deliver(receipt(revoked: true)) + precondition(finished == before, "Revoked transaction never finishes as delivered") + requests = 0 + let otherProduct = await deliver(receipt(product: "unrelated.product")) + precondition(otherProduct == .ignored && requests == 0 && finished == before, "Other products stay with their owner") + _ = await ledger.deliver(receipt(), credential: { nil }, isCurrent: { _ in true }, request: request, + finish: { finished += 1 }) + precondition(finished == before, "Signed-out recovery waits for sign-in") + _ = await ledger.deliver(receipt(), credential: { .init(bearer: "access", generation: generation) }, + isCurrent: { _ in true }, request: { _, _ in Data("{}".utf8) }, finish: { finished += 1 }) + precondition(finished == before, "Malformed acknowledgement cannot finish") + let unbound = StoreCreditDelivery.Receipt(id: "1234", productID: StoreCreditDelivery.productID, + accountToken: nil, environment: "Sandbox", revoked: false, jws: "signed") + requests = 0 + _ = await deliver(unbound) + precondition(finished == before && requests == 1, "Unbound transactions cannot claim another account") + + var concurrentRequestCount = 0, release: CheckedContinuation? + let concurrent = Task { @MainActor in + await ledger.deliver(receipt(), credential: { .init(bearer: "access", generation: generation) }, + isCurrent: { _ in true }, request: { action, auth in + concurrentRequestCount += 1 + if concurrentRequestCount == 1 { await withCheckedContinuation { release = $0 } } + return try await request(action, auth) + }, finish: { finished += 1 }) + } + while release == nil { await Task.yield() } + let duplicate = await deliver(receipt()) + precondition(duplicate == .ignored, "Purchase result and transaction update cannot redeem concurrently") + release?.resume() + let recovered = await concurrent.value + precondition(recovered == .alreadyAdded && finished == before + 1 && concurrentRequestCount == 2) + print("PASS: 15 StoreKit delivery cases; only a durable, matching-account grant finishes a transaction") + } +} diff --git a/apple/whistlegraph/Tests/ai-consent.test.mjs b/apple/whistlegraph/Tests/ai-consent.test.mjs new file mode 100644 index 0000000000..4539597af8 --- /dev/null +++ b/apple/whistlegraph/Tests/ai-consent.test.mjs @@ -0,0 +1,200 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createAIConsentGate} from '../Resources/Web/ai-consent.mjs'; +import {runPersonalTurn} from '../../../aesel/src/personal-relay.mjs'; +import {MusicalInputSocket} from '../../../aesel/src/musical-input-socket.mjs'; + +const inference = 'https://aesthetic.computer/api/easel-inference'; +const musical = 'https://aesthetic.computer/api/easel-musical-jev'; +const sessions = 'https://help.aesthetic.computer/api/aesel/sessions'; +const pause = () => new Promise(resolve => setTimeout(resolve, 0)); +const isAbort = error => error.name === 'AbortError'; +const routes = [inference, musical, sessions, sessions + '/session-1/turn', + sessions + '/session-1?after=12', sessions + '/session-1/respond']; + +for (const route of routes) test('denies before network: ' + route, async () => { + let calls = 0, required = 0; + const gate = createAIConsentGate({fetch: async () => { calls++; return Response.json({}); }, onRequired: () => required++}); + await assert.rejects(gate.fetch(route, {method: 'POST', body: 'private input'}), /Allow AI creation/); + assert.equal(calls, 0); + assert.equal(required, 1); +}); + +for (const [label, input] of [ + ['URL', new URL(inference)], ['Request', new Request(musical)], ['relative path', '/api/easel-inference'], +]) test('denies Fetch ' + label + ' input before network', async () => { + const gate = createAIConsentGate({fetch: () => { assert.fail('Request must not reach network'); }}); + await assert.rejects(gate.fetch(input), /Allow AI creation/); +}); + +test('only explicit true grants consent, and an allowed request preserves its body and headers', async () => { + const calls = []; + const gate = createAIConsentGate({fetch: async (input, options) => { + calls.push({input, options}); + return new Response('data: complete\n\n', {status: 201, headers: {'x-request-id': 'fixture'}}); + }}); + for (const value of [undefined, null, 1, 'true', {}]) { + gate.setAllowed(value); + await assert.rejects(gate.fetch(inference), /Allow AI creation/); + } + gate.setAllowed(true); + const body = JSON.stringify({messages: [{role: 'user', content: 'private prompt'}]}); + const response = await gate.fetch(inference, {method: 'POST', headers: {Authorization: 'fixture'}, body}); + assert.equal(response.status, 201); + assert.equal(response.headers.get('x-request-id'), 'fixture'); + assert.equal(await response.text(), 'data: complete\n\n'); + assert.equal(calls.length, 1); + assert.equal(calls[0].options.body, body); + assert.equal(calls[0].options.headers.Authorization, 'fixture'); + gate.setAllowed(false); + assert.equal(calls[0].options.signal.aborted, false, 'completed streams release their controller'); +}); + +test('revocation aborts requests still waiting for response headers', async () => { + let signal; + const gate = createAIConsentGate({allowed: true, fetch: (_input, options) => { + signal = options.signal; + return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), {once: true})); + }}); + const rejection = assert.rejects(gate.fetch(inference), isAbort); + gate.setAllowed(false); + await rejection; + assert.equal(signal.aborted, true); + await assert.rejects(gate.fetch(inference), /Allow AI creation/); +}); + +test('revocation rejects late responses even when a transport ignores abort', async () => { + let respond, cancelled = false; + const gate = createAIConsentGate({allowed: true, fetch: () => new Promise(resolve => respond = resolve)}); + const rejection = assert.rejects(gate.fetch(inference), isAbort); + gate.setAllowed(false); + respond(new Response(new ReadableStream({cancel() { cancelled = true; }}))); + await rejection; + assert.equal(cancelled, true); +}); + +test('revocation errors a waiting stream and cancels its reader', {timeout: 2000}, async () => { + let cancelled = false; + const gate = createAIConsentGate({allowed: true, fetch: async () => new Response(new ReadableStream({ + cancel() { cancelled = true; }, + }))}); + const response = await gate.fetch(inference); + const reader = response.body.getReader(); + const rejection = assert.rejects(reader.read(), isAbort); + gate.setAllowed(false); + await rejection; + assert.equal(cancelled, true); +}); + +test('revocation discards queued response chunks, even before the caller starts reading', {timeout: 2000}, async () => { + const gate = createAIConsentGate({allowed: true, fetch: async () => new Response(new ReadableStream({ + start(stream) { stream.enqueue(new TextEncoder().encode('private buffered output')); }, + }))}); + const response = await gate.fetch(inference); + await pause(); + gate.setAllowed(false); + gate.setAllowed(true); + await assert.rejects(response.text(), isAbort, 'granting again cannot revive an old stream'); +}); + +test('upstream abort and response cancellation propagate to the transport', {timeout: 2000}, async () => { + const signals = []; + const gate = createAIConsentGate({allowed: true, fetch: async (_input, options) => { + signals.push(options.signal); + return new Response(new ReadableStream({})); + }}); + const upstream = new AbortController(); + const response = await gate.fetch(new Request(inference, {signal: upstream.signal})); + const rejection = assert.rejects(response.body.getReader().read(), isAbort); + upstream.abort(); + await rejection; + assert.equal(signals[0].aborted, true); + const next = await gate.fetch(inference); + await next.body.cancel(); + assert.equal(signals[1].aborted, true); +}); + +test('already aborted upstream signals do not start a request', async () => { + const gate = createAIConsentGate({allowed: true, fetch: () => assert.fail('No request expected')}); + await assert.rejects(gate.fetch(inference, {signal: AbortSignal.abort()}), isAbort); +}); + +test('no-body responses are preserved and removed from active requests', async () => { + let signal; + const response = new Response(null, {status: 204}); + const gate = createAIConsentGate({allowed: true, fetch: async (_input, options) => { signal = options.signal; return response; }}); + assert.equal(await gate.fetch(inference), response); + gate.setAllowed(false); + assert.equal(signal.aborted, false); +}); + +test('interrupt remains usable after revocation; other personal relay endpoints stay denied', async () => { + const calls = []; + const gate = createAIConsentGate({fetch: async input => { calls.push(input); return Response.json({ok: true}); }}); + const response = await gate.fetch(sessions + '/session-1/interrupt', {method: 'POST', body: '{}'}); + assert.deepEqual(await response.json(), {ok: true}); + await assert.rejects(gate.fetch(sessions + '/session-1/turn'), /Allow AI creation/); + await assert.rejects(gate.fetch(sessions + '/session-1/respond'), /Allow AI creation/); + assert.equal(calls.length, 1); +}); + +test('account, credit balances and purchases retain ordinary fetch behavior', async () => { + const calls = [], response = Response.json({ok: true}); + const gate = createAIConsentGate({fetch: async (input, options) => { calls.push({input, options}); return response; }}); + const signal = new AbortController().signal; + for (const route of ['/api/easel-credits', '/api/handle?for=fixture', '/api/whistlegraph-iap']) { + assert.equal(await gate.fetch('https://aesthetic.computer' + route, {signal}), response); + } + assert.equal(await gate.fetch('https://aesthetic.us.auth0.com/userinfo', {signal}), response); + gate.setAllowed(false); + assert.equal(calls.length, 4); + assert.ok(calls.every(call => call.options.signal === signal)); + assert.equal(signal.aborted, false); +}); + +test('real personal-relay lifecycle can interrupt after consent revocation', {timeout: 2000}, async () => { + const calls = [], controller = new AbortController(); + let startedPoll; + const polling = new Promise(resolve => startedPoll = resolve); + const gate = createAIConsentGate({allowed: true, fetch: async (input, options) => { + calls.push(input); + if (input === sessions) return Response.json({thread: {id: 'session-1'}}); + if (input.endsWith('/turn') || input.endsWith('/interrupt')) return Response.json({ok: true}); + startedPoll(); + return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(options.signal.reason), {once: true})); + }}); + const rejection = assert.rejects(runPersonalTurn({token: 'fixture', model: 'anthropic/claude-opus-5', + content: 'private prompt', fetch: gate.fetch, signal: controller.signal, pollMs: 0}), isAbort); + await polling; + gate.setAllowed(false); + controller.abort(); // engine syncAIConsent also interrupts its owning server. + await rejection; + await pause(); + assert.deepEqual(calls, [sessions, sessions + '/session-1/turn', sessions + '/session-1?after=0', sessions + '/session-1/interrupt']); +}); + +test('musical socket stays closed before grant and suspending it cancels pending observations', {timeout: 2000}, async () => { + const sockets = [], sent = []; + class Socket { + constructor(url) { this.url = url; this.readyState = 1; sockets.push(this); } + send(message) { sent.push(JSON.parse(message)); } + close() { this.readyState = 3; this.onclose?.(); } + } + const gate = createAIConsentGate({fetch: () => assert.fail('No HTTP fallback expected')}); + const socket = new MusicalInputSocket({token: () => gate.allowed ? 'fixture' : null, WebSocketImpl: Socket, fetchImpl: gate.fetch}); + const send = (...args) => { gate.require(); return socket.fetch(...args); }; + socket.resume(); + assert.equal(sockets.length, 0); + assert.throws(() => send(musical, {}), /Allow AI creation/); + gate.setAllowed(true); socket.resume(); + assert.equal(sockets[0].url, 'wss://aesthetic.computer/api/easel-musical-stream'); + sockets[0].onopen(); + sockets[0].onmessage({data: JSON.stringify({type: 'ready'})}); + const rejection = assert.rejects(send(musical, {body: JSON.stringify({sessionId: 'observation', sequence: 1})}), /socket_closed/); + gate.setAllowed(false); socket.suspend(); // same pair used by engine syncAIConsent + await rejection; + assert.equal(sockets[0].readyState, 3); + assert.equal(socket.pending.size, 0); + assert.deepEqual(sent.map(message => message.type), ['authenticate', 'observation']); + assert.throws(() => send(musical, {}), /Allow AI creation/); +}); diff --git a/apple/whistlegraph/Tests/inference-error.test.mjs b/apple/whistlegraph/Tests/inference-error.test.mjs new file mode 100644 index 0000000000..4a71f884fd --- /dev/null +++ b/apple/whistlegraph/Tests/inference-error.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {inferenceError} from '../Resources/Web/inference-error.mjs'; + +test('exhausted inference directs the user to this app purchase screen', () => { + const message = inferenceError({billing: true, message: 'Out of braincells — buy more from the braincell meter in Aesel, or switch provider with /provider. Free braincells reset at midnight UTC.'}); + assert.match(message, /Brain settings/); + assert.match(message, /midnight UTC/); + assert.doesNotMatch(message, /Aesel|\/provider/); +}); + +test('daily limits and unrelated errors retain their actual reason', () => { + for (const message of ["This request would exceed today's limit; it resets in 2h.", 'Your account is unavailable.', 'The preview failed.']) { + assert.equal(inferenceError({billing: true, message}), message); + } + assert.equal(inferenceError(undefined), ''); +}); diff --git a/apple/whistlegraph/Tests/native-bridge.test.cjs b/apple/whistlegraph/Tests/native-bridge.test.cjs index 90bab66c30..c5b98c0d14 100644 --- a/apple/whistlegraph/Tests/native-bridge.test.cjs +++ b/apple/whistlegraph/Tests/native-bridge.test.cjs @@ -92,6 +92,7 @@ const server = http.createServer(async (req, res) => { const guideSeed=Object.fromEntries(await Promise.all(['pieces.md','screen.md','hand.md','kidlisp.md','api.json'].map(async name=>['/easel/context/'+name,await readFile(resolve(root,'easel/context',name),'utf8')]))); await page.evaluateOnNewDocument((seed,nativeShell,personal)=>{ window.__walkiewareNativeShell=nativeShell; + window.__whistlegraphAIConsent={handle:personal?'jeffrey':'fixture',creation:true}; window.__aeselGuides=seed; window.__walkiewareDisableThread=true; window.__guideFetches=0; @@ -290,7 +291,8 @@ const server = http.createServer(async (req, res) => { assert.equal(await page.evaluate(()=>localStorage.getItem('walkieware-source-inflight')),null,'successful crash recovery clears its journal'); const ledgerBeforeStory=await page.evaluate(()=>localStorage.getItem('walkieware-source-versions')); await page.evaluate(()=>walkiewareNativeCommand({action:'presentVersion',version:1})); - await page.waitForFunction(()=>__nativeMessages.some(m=>m.action==='narrationReady'&&m.version===1)); + await page.waitForFunction(()=>__nativeMessages.some(m=>m.action==='presentation'&&m.version===1)); + assert.equal(await page.evaluate(()=>__nativeMessages.filter(m=>m.action==='presentation'&&m.version===1).at(-1).source),JSON.parse(ledgerBeforeStory).versions.find(v=>v.id===1).source,'separate native story runtime receives the selected source'); assert.equal(await page.evaluate(()=>localStorage.getItem('walkieware-source-versions')),ledgerBeforeStory,'story playback must not change the saved head or ledger'); await page.evaluate(()=>walkiewareNativeCommand({action:'endPresentation'})); assert.equal(await page.evaluate(()=>localStorage.getItem('walkieware-source-versions')),ledgerBeforeStory); diff --git a/apple/whistlegraph/Tests/source-editor-bridge.test.cjs b/apple/whistlegraph/Tests/source-editor-bridge.test.cjs new file mode 100644 index 0000000000..9ec281748d --- /dev/null +++ b/apple/whistlegraph/Tests/source-editor-bridge.test.cjs @@ -0,0 +1,104 @@ +// Exercises the real engine/bridge with deterministic native render events. +// The native runtime itself still needs a device preview test. +const assert = require('node:assert/strict'); +const {resolve, extname} = require('node:path'); +const {readFile} = require('node:fs/promises'); +const http = require('node:http'); +const puppeteer = require('puppeteer'); +const root = resolve(__dirname, '../Resources/Web'); +const base = 'export function paint({wipe}) {wipe("navy");}\n//' + 'complete source '.repeat(600) + '\n// final sentinel'; +const edited = 'export function paint({wipe}) {wipe("pink");}'; +const server = http.createServer(async (request, response) => { + try { + const path = resolve(root, '.' + new URL(request.url, 'http://local').pathname); + if (!path.startsWith(root + '/')) throw Error('Bad path'); + const data = await readFile(path); + response.setHeader('Content-Type', ({'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json'})[extname(path)] || 'application/octet-stream'); + response.end(data); + } catch { response.writeHead(404); response.end(); } +}); + +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await puppeteer.launch({headless: true, executablePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); + try { + const page = await browser.newPage(), errors = [], requests = []; + page.on('pageerror', error => errors.push(error.message)); + await page.setRequestInterception(true); + page.on('request', request => { + requests.push(request.url()); + if (request.url().startsWith('https://')) { + request.respond({status: 200, contentType: 'text/html', body: 'Native preview fixture'}); + } else request.continue(); + }); + await page.evaluateOnNewDocument(source => { + window.__walkiewareNativeShell = true; + window.__walkiewareDisableThread = true; + window.__nativeMessages = []; + window.__sourceRenderMode = 'paint'; + localStorage.setItem('walkieware-source', source); + window.webkit = {messageHandlers: {walkie: {postMessage: message => { + window.__nativeMessages.push(message); + if (message.action !== 'render') return; + const fail = window.__sourceRenderMode === 'runtime' && message.source.includes('runtimeFailure'); + setTimeout(async () => { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(message.source)); + const sourceHash = [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); + const proof = {sourceHash, requestID: message.renderID}; + window.walkiewareEngineEvent({kind: 'previewEvent', event: {...proof, kind: 'painted'}}); + if (fail) setTimeout(() => window.walkiewareEngineEvent({kind: 'previewEvent', event: {...proof, + kind: 'console', event: {level: 'error', message: 'Paint failure: runtimeFailure'}}}), 100); + }, 20); + }}}}; + }, base); + await page.goto('http://127.0.0.1:' + server.address().port + '/index.html?walkie=1'); + await page.waitForFunction(() => !!window.walkiewareSourceEditor); + await page.evaluate(() => window.walkiewareEngineEvent({kind: 'previewReady'})); + const original = await page.evaluate(() => window.walkiewareSourceEditor.read()); + assert.equal(original.source, base); + assert.ok(original.source.length > 6000); + const saved = await page.evaluate(async ({document, source}) => { + window.__editorDocument = document; + return window.walkiewareSourceEditor.apply({...document, source}); + }, {document: original, source: edited}); + assert.equal(saved.version, 1); + let state = await page.evaluate(() => ({ledger: JSON.parse(localStorage.getItem('walkieware-source-versions')), + source: localStorage.getItem('walkieware-source'), busy: window.walkiewareIsBusy()})); + assert.equal(state.ledger.head, 1); + assert.equal(state.ledger.versions[1].request, 'Manual source edit'); + assert.equal(state.source, edited); + assert.equal(state.busy, false); + + const staleError = await page.evaluate(async source => { + try { await window.walkiewareSourceEditor.apply({...window.__editorDocument, source}); } + catch (error) { return error.message; } + }, edited + '\n// stale'); + assert.match(staleError, /selected version changed/); + const syntaxError = await page.evaluate(async () => { + try { await window.walkiewareSourceEditor.apply({...await window.walkiewareSourceEditor.read(), source: 'export function paint('}); } + catch (error) { return error.message; } + }); + assert.match(syntaxError, /complete JavaScript module/); + const runtimeError = await page.evaluate(async () => { + window.__sourceRenderMode = 'runtime'; + try { await window.walkiewareSourceEditor.apply({...await window.walkiewareSourceEditor.read(), + source: 'export function paint({wipe}) {wipe("red"); runtimeFailure();}'}); } + catch (error) { return error.message; } + }); + assert.match(runtimeError, /runtimeFailure/); + await page.waitForFunction(() => !window.walkiewareIsBusy()); + state = await page.evaluate(() => ({ledger: JSON.parse(localStorage.getItem('walkieware-source-versions')), + source: localStorage.getItem('walkieware-source'), renders: window.__nativeMessages.filter(message => message.action === 'render')})); + assert.equal(state.ledger.head, 1); + assert.equal(state.ledger.versions.length, 2); + assert.equal(state.source, edited); + assert.equal(state.renders.at(-1).source, edited); + const final = await page.evaluate(() => window.walkiewareSourceEditor.read()); + assert.equal(final.source, edited); + assert.equal(final.sourceHash, saved.sourceHash); + assert.equal(requests.filter(url => /easel-inference|easel-musical|openrouter|openai|anthropic/.test(url)).length, 0, + 'manual source edits require no sign-in, AI consent, provider requests or credit debit'); + assert.deepEqual(errors, []); + console.log('PASS: full source bridge, unsigned local commit, stale/syntax rejection, delayed-runtime rollback and zero AI requests.'); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); diff --git a/apple/whistlegraph/Tests/source-editor.test.mjs b/apple/whistlegraph/Tests/source-editor.test.mjs new file mode 100644 index 0000000000..a73b2f78b2 --- /dev/null +++ b/apple/whistlegraph/Tests/source-editor.test.mjs @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {SourceEditor} from '../Resources/Web/source-editor.mjs'; +import {PieceVersions} from '../Resources/Web/piece-versions.mjs'; +import {sourceChecks} from '../../../aesel/src/edit-contract.mjs'; + +const hash = async source => createHash('sha256').update(source).digest('hex'); +const base = 'export function paint({wipe}) { wipe("navy"); }'; +const edited = 'export function paint({wipe}) { wipe("pink"); }'; + +function fixture(options = {}) { + const values = new Map(); + const storage = {getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)}; + const ledger = new PieceVersions(storage, 'versions', options.source ?? base); + const state = {piece: 'piece-a', code: 'wgSource', busy: false, recording: false}; + let clock = 0, proof, preview = ledger.head.source, requestID = 0, restores = 0, finishes = 0; + const readState = () => ({...state, version: ledger.head.id, source: ledger.head.source}); + const editor = new SourceEditor({ + state: readState, checks: sourceChecks, hash, + begin() { state.busy = true; }, + render(source) { + preview = source; + proof = {sourceHash: createHash('sha256').update(source).digest('hex'), requestID: ++requestID, + rendered: true, logs: []}; + options.onRender?.(proof); + return requestID; + }, + inspect: () => proof, + commit: command => ledger.commit(command), + restore() { preview = ledger.head.source; restores++; }, + finish() { state.busy = false; finishes++; }, + now: () => clock, + wait: async ms => { clock += ms; await options.onWait?.({proof, clock, ledger, state, editor}); }, + timeout: 200, settle: 50, + }); + return {editor, ledger, storage, state, get preview() { return preview; }, get restores() { return restores; }, + get finishes() { return finishes; }, get requestID() { return requestID; }}; +} + +test('reads complete source beyond ticker limit and applies a durable child with no inference dependency', async () => { + const source = base + '\n//' + 'whole source '.repeat(2000) + '\n// final sentinel'; + const f = fixture({source}); + const doc = await f.editor.read(); + assert.equal(doc.source, source); + assert.ok(doc.source.length > 6000); + const saved = await f.editor.apply({...doc, source: edited}); + assert.equal(saved.version, 1); + assert.equal(saved.sourceHash, await hash(edited)); + assert.equal(f.ledger.head.parent, 0); + assert.equal(f.ledger.value.versions[0].source, source); + assert.equal(new PieceVersions(f.storage, 'versions').head.source, edited); + assert.equal(f.preview, edited); + assert.equal(f.state.busy, false); + assert.equal(f.restores, 0); +}); + +test('edits the selected historical version without deleting later branches', async () => { + const f = fixture(); + f.ledger.commit({source: edited, request: 'Earlier edit'}); + f.ledger.checkout(0); + const saved = await f.editor.apply({...await f.editor.read(), source: base + '\n// branch'}); + assert.equal(saved.version, 2); + assert.equal(f.ledger.head.parent, 0); + assert.equal(f.ledger.value.versions[1].source, edited); +}); + +for (const [name, source, message] of [ + ['syntax', 'export function paint(', /complete JavaScript module/], + ['API misuse', 'export function paint({ink}) { ink.box(1,2,3); }', /standalone drawing function/], + ['empty source', ' ', /complete JavaScript piece/], + ['oversize edit', '//'+ 'x'.repeat(499998), /500 KB/], +]) test(`rejects ${name} before rendering or changing history`, async () => { + const f = fixture(); + await assert.rejects(f.editor.apply({...await f.editor.read(), source}), message); + assert.equal(f.ledger.head.id, 0); + assert.equal(f.requestID, 0); + assert.equal(f.state.busy, false); +}); + +test('an empty starter can be read and replaced with a complete piece', async () => { + const f = fixture({source: ''}); + const doc = await f.editor.read(); + assert.equal(doc.source, ''); + await f.editor.apply({...doc, source: edited}); + assert.equal(f.ledger.head.source, edited); +}); + +test('identical source does not create a redundant version', async () => { + const f = fixture(); + const result = await f.editor.apply(await f.editor.read()); + assert.equal(result.changed, false); + assert.equal(f.requestID, 0); + assert.equal(f.ledger.value.versions.length, 1); +}); + +for (const field of ['piece', 'version', 'sourceHash']) test(`rejects stale ${field}`, async () => { + const f = fixture(), doc = await f.editor.read(); + await assert.rejects(f.editor.apply({...doc, [field]: field === 'version' ? 99 : 'stale', source: edited}), /selected version changed/); + assert.equal(f.requestID, 0); +}); + +for (const flag of ['busy', 'recording']) test(`rejects source application while ${flag}`, async () => { + const f = fixture(), doc = await f.editor.read(); + f.state[flag] = true; + await assert.rejects(f.editor.apply({...doc, source: edited}), /current request or recording/); + assert.equal(f.requestID, 0); +}); + +for (const [name, change, message] of [ + ['runtime error', proof => proof.logs.push({level: 'error', text: 'Paint failure: broken'}), /Paint failure/], + ['stale source hash', proof => proof.sourceHash = 'old', /does not match/], + ['stale render request', proof => proof.requestID = -1, /preview changed/], + ['unpainted timeout', proof => proof.rendered = false, /did not render/], + ['cancel', proof => proof.cancelled = true, /stopped/], +]) test(`restores previous version after ${name}`, async () => { + const f = fixture({onRender: change}); + await assert.rejects(f.editor.apply({...await f.editor.read(), source: edited}), message); + assert.equal(f.preview, base); + assert.equal(f.ledger.head.id, 0); + assert.equal(f.restores, 1); + assert.equal(f.finishes, 1); + assert.equal(f.state.busy, false); +}); + +test('waits after first paint and rejects a delayed runtime failure', async () => { + const f = fixture({onWait({clock, proof}) { if (clock === 25) proof.runtimeFailed = true; }}); + await assert.rejects(f.editor.apply({...await f.editor.read(), source: edited}), /could not run/); + assert.equal(f.ledger.head.id, 0); + assert.equal(f.preview, base); +}); + +test('a failed durable write restores preview and leaves all saved versions intact', async () => { + const f = fixture(), doc = await f.editor.read(); + f.storage.setItem = () => { throw Error('Storage full'); }; + await assert.rejects(f.editor.apply({...doc, source: edited}), /Storage full/); + assert.equal(f.ledger.head.id, 0); + assert.equal(f.preview, base); + assert.equal(f.state.busy, false); +}); + +test('stale completion cannot overwrite a newly selected version', async () => { + const f = fixture({onWait({clock, ledger}) { + if (clock === 25) ledger.commit({source: base + '\n// external update'}); + }}); + await assert.rejects(f.editor.apply({...await f.editor.read(), source: edited}), /selected version changed/); + assert.equal(f.ledger.head.source, base + '\n// external update'); + assert.equal(f.preview, f.ledger.head.source); +}); + +test('duplicate application is rejected while the first preview is being checked', async () => { + let duplicateChecked = false, doc; + const f = fixture({async onWait({editor}) { + if (duplicateChecked) return; + duplicateChecked = true; + await assert.rejects(editor.apply({...doc, source: edited}), /already being checked/); + }}); + doc = await f.editor.read(); + await f.editor.apply({...doc, source: edited}); + assert.equal(f.ledger.value.versions.length, 2); + assert.ok(duplicateChecked); +}); diff --git a/apple/whistlegraph/UITests/HeaderSheetsTests.swift b/apple/whistlegraph/UITests/HeaderSheetsTests.swift index ce687ba1e6..2c26d720da 100644 --- a/apple/whistlegraph/UITests/HeaderSheetsTests.swift +++ b/apple/whistlegraph/UITests/HeaderSheetsTests.swift @@ -20,7 +20,11 @@ final class HeaderSheetsTests: XCTestCase { let image = XCTAttachment(screenshot: app.screenshot()) image.name = "Brain cost in " + unit; image.lifetime = .keepAlways; add(image) } + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG XCTAssertTrue(app.buttons["brain-buy-tezos"].exists) + #else + XCTAssertFalse(app.buttons["brain-buy-tezos"].exists) + #endif app.buttons["Done"].tap() app.terminate(); app.launch() XCTAssertTrue(brain.waitForExistence(timeout: 30)) @@ -30,6 +34,7 @@ final class HeaderSheetsTests: XCTestCase { app.buttons["Done"].tap() } + #if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG // Opens Temple; never approves a wallet connection, signature or payment. func testPhoneLiveTezosCheckout() { let app = XCUIApplication() @@ -112,6 +117,8 @@ final class HeaderSheetsTests: XCTestCase { let screen = XCTAttachment(screenshot: wallet.screenshot()); screen.name = "Temple handoff screen"; screen.lifetime = .keepAlways; add(screen) } + #endif + func testPieceAudioStartsWithoutTouch() { let app = XCUIApplication() app.launchEnvironment["WALKIE_NATIVE_SCREEN_FIXTURE"] = "audio" @@ -160,6 +167,26 @@ final class HeaderSheetsTests: XCTestCase { return app } + #if !WHISTLEGRAPH_INTERNAL_PAYMENTS + func testAppStoreMonetizationControls() { + let app = launch() + app.buttons["brain-settings"].tap() + app.swipeUp() + let units = app.segmentedControls["brain-cost-unit"] + XCTAssertTrue(units.waitForExistence(timeout: 15)) + for unit in ["Braincells", "USD", "Tezos"] { XCTAssertTrue(units.buttons[unit].exists) } + let store = app.descendants(matching: .any).matching(NSPredicate(format: + "identifier == %@ OR identifier == %@", "brain-buy-app-store", "brain-app-store-status")).firstMatch + XCTAssertTrue(store.waitForExistence(timeout: 15), "App Store product or honest unavailable state") + XCTAssertFalse(app.buttons["brain-buy-tezos"].exists) + app.buttons["Done"].tap() + XCTAssertTrue(waitForDisappearance(of: units)) + app.buttons["workspace-settings"].tap() + XCTAssertTrue(app.buttons["pieces-new"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["pieces-mint"].exists) + } + #endif + // Read-only inspection of the installed phone account and its current piece. func testPhoneBrainPanelAndPieceMenu() { let app = XCUIApplication() diff --git a/apple/whistlegraph/UITests/WhistlegraphMintTests.swift b/apple/whistlegraph/UITests/WhistlegraphMintTests.swift index 7e5b85fd5c..a41904707c 100644 --- a/apple/whistlegraph/UITests/WhistlegraphMintTests.swift +++ b/apple/whistlegraph/UITests/WhistlegraphMintTests.swift @@ -1,3 +1,4 @@ +#if WHISTLEGRAPH_INTERNAL_PAYMENTS && DEBUG import XCTest import UIKit @@ -84,3 +85,5 @@ final class WhistlegraphMintTests: XCTestCase { } } } + +#endif diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index 29eba7db0a..e7ad4e71f0 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -10,14 +10,18 @@ 0300D7A47360F4B79AB5B31F /* SwipeToTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212791F2E33DED2E1C368651 /* SwipeToTypeTests.swift */; }; 03C5B25FC742430AFD02AD1E /* StoryMovieSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 250E0B1999C7810299ACB7A9 /* StoryMovieSheet.swift */; }; 0438F503606B4A37262B7CCC /* BrainSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E61EF96840670F2F894F970 /* BrainSettings.swift */; }; + 08B1D8E0B17FC2AD2643193B /* WhistlegraphDeleteAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = C534DE3AC6E813F8BDFE5244 /* WhistlegraphDeleteAccount.swift */; }; + 09EF064DE749131E69700C96 /* PreviewNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1916E8033C031E046E760C /* PreviewNavigation.swift */; }; 10C0A16D4803EC60EAC29531 /* RecordingTrim.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1EFF8BAE7678E320E3DCA0 /* RecordingTrim.swift */; }; 123228FADA6696AB354306C7 /* InputButtonLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35643029A3E82EEDEAC8DED6 /* InputButtonLabels.swift */; }; 13604BE92157CDBFDD32AA19 /* WorkspaceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147EAC245AD250B9B652F903 /* WorkspaceCoordinator.swift */; }; 14FFBE48767E2576693FB7B3 /* MusicalInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D233DBB6F87184A91C13D41 /* MusicalInput.swift */; }; 15EE658816EEA4B8BE6AAF5A /* StoryVoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49854384EFBA11ABDE4F70C6 /* StoryVoice.swift */; }; 19717448D7598ADAABBF739E /* HeaderSheetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F98F7D3CE551FC6458D1F37 /* HeaderSheetsTests.swift */; }; + 24D22223CEEE7F360CEAEADC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FCE365B860DBD53E42DB2824 /* PrivacyInfo.xcprivacy */; }; 26D07334E2DEBA6705671BB0 /* WhistlegraphApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5F188938A81E704BB7F9F9 /* WhistlegraphApp.swift */; }; 275555C74507A6F62B0F037B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF5F023E15041FF61F9FD358 /* Assets.xcassets */; }; + 2D8D89A7A8C4657353319EBE /* WhistlegraphSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB3C6140E5D943C3363C353 /* WhistlegraphSource.swift */; }; 2F01E350A7FF55359B002344 /* ButtonSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABFC6C11146A1540B635EE6 /* ButtonSounds.swift */; }; 2F15D5A580A90A18E92D90F1 /* AudioBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC14C186FE1CB5B19AD1D72 /* AudioBenchmark.swift */; }; 3CF6178F7BA1702ADFA457B7 /* StoryControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C42750EF4F7E96146B03585 /* StoryControls.swift */; }; @@ -26,14 +30,18 @@ 461CDD30BD59491846FCFBF6 /* StoryPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2FC346829168FF4AEEED3 /* StoryPreview.swift */; }; 47DADA794ABD0CD203CFF48C /* StoryExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B494AD8CD86FCC5DE0E4CC /* StoryExport.swift */; }; 49D6E6ED3128002039D4F931 /* UtteranceRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9B92C3457F07429C8C2600 /* UtteranceRecording.swift */; }; + 4A8B9BCE5403659FFB71EA65 /* AccountDeletionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 725C2A9ACD0323F49EFFDFC1 /* AccountDeletionClient.swift */; }; 4B35B066F49BDD9BD8B90B5A /* WhistlegraphScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66B3ADC4D6D837FABC53B8F /* WhistlegraphScreen.swift */; }; + 55A7061DE3B5F747C7BE96CE /* WhistlegraphBraincells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BFA0F39F2F514E799291F6 /* WhistlegraphBraincells.swift */; }; 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 */; }; 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 */; }; 63AC2EF656BB908FC6B195DA /* WhistlegraphMint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FEBB51736F61D93DD1A7A /* WhistlegraphMint.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 */; }; 749AC25AE02AAE4BCDA5683A /* VisualCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF0A171A5771A7A1FD56852 /* VisualCapture.swift */; }; 77291EEE768914D5F5EDB84B /* PlaybackWord.swift in Sources */ = {isa = PBXBuildFile; fileRef = E97104E13862EE30444D07C9 /* PlaybackWord.swift */; }; @@ -43,6 +51,7 @@ 87EDDAE34B992D308236F1A9 /* StoryCardsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B4655A00B6DC810F7203D1 /* StoryCardsTests.swift */; }; 8A9F944BA95C971D70ADDF6B /* VersionNarrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D137824491A01D4C19417B8 /* VersionNarrator.swift */; }; 938427295F53A8EC1E70C0A9 /* PreviewFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478EF62AE288CE06A64A3EA4 /* PreviewFormat.swift */; }; + 9BCDA054A876F147E2022B66 /* StoreCreditDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D7D7F240AB7ADAA1340FE5C /* StoreCreditDelivery.swift */; }; 9DBA1AF2AD7E3EDF69C8132A /* CodeTicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E2AAA0A3F2BA1A5AEBED58 /* CodeTicker.swift */; }; 9E5F0B7DC1CB3E9F4FBE9B90 /* WhistlegraphWoodFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14D13AF43AD0C735B4EA3CB /* WhistlegraphWoodFrame.swift */; }; 9E961DFF3A1C8C9BD8E57866 /* PieceAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31AAC74C07F21688CEFF544F /* PieceAudio.swift */; }; @@ -58,6 +67,7 @@ E55F422280707B98E1D3E842 /* StoryTape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AEEA7D7E12C650CB618757 /* StoryTape.swift */; }; 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 */; }; FAC2911EFFCA887527D2415F /* WhistlegraphDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D9A8C801D3B9D1192D1143 /* WhistlegraphDrawing.swift */; }; /* End PBXBuildFile section */ @@ -74,14 +84,18 @@ /* Begin PBXFileReference section */ 03AEEA7D7E12C650CB618757 /* StoryTape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryTape.swift; sourceTree = ""; }; 060870DF76C6FC078A48A276 /* WhistlegraphAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphAccount.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; + 1ED23B0C39652687E9ECFFAF /* PaymentBuild.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentBuild.swift; sourceTree = ""; }; 212791F2E33DED2E1C368651 /* SwipeToTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToTypeTests.swift; sourceTree = ""; }; 21D4A924CCE7B4BEC62E98A9 /* NativeSignIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignIn.swift; sourceTree = ""; }; 250E0B1999C7810299ACB7A9 /* StoryMovieSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryMovieSheet.swift; sourceTree = ""; }; + 2AB3C6140E5D943C3363C353 /* WhistlegraphSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphSource.swift; sourceTree = ""; }; + 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 = ""; }; 3F98F7D3CE551FC6458D1F37 /* HeaderSheetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSheetsTests.swift; sourceTree = ""; }; @@ -101,6 +115,8 @@ 5DF0A171A5771A7A1FD56852 /* VisualCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualCapture.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 = ""; }; + 725C2A9ACD0323F49EFFDFC1 /* AccountDeletionClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionClient.swift; sourceTree = ""; }; 7848DB9FED6B955BC193F650 /* LiveTranscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTranscription.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 = ""; }; @@ -117,6 +133,7 @@ B7A25F803FAC121BFECC1AD7 /* SpeechCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechCapture.swift; sourceTree = ""; }; B8B4655A00B6DC810F7203D1 /* StoryCardsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryCardsTests.swift; sourceTree = ""; }; BA5F188938A81E704BB7F9F9 /* WhistlegraphApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphApp.swift; sourceTree = ""; }; + C534DE3AC6E813F8BDFE5244 /* WhistlegraphDeleteAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphDeleteAccount.swift; sourceTree = ""; }; CB5E6AF3AF51BAA89BAC231E /* RecordedTranscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordedTranscription.swift; sourceTree = ""; }; CC1EFF8BAE7678E320E3DCA0 /* RecordingTrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingTrim.swift; sourceTree = ""; }; D25F85E540016CEBC3A6BC09 /* WhistlegraphTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphTheme.swift; sourceTree = ""; }; @@ -125,6 +142,9 @@ 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 = ""; }; + 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 = ""; }; FF5F023E15041FF61F9FD358 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ @@ -133,6 +153,7 @@ isa = PBXGroup; children = ( FF5F023E15041FF61F9FD358 /* Assets.xcassets */, + FCE365B860DBD53E42DB2824 /* PrivacyInfo.xcprivacy */, ); path = Resources; sourceTree = ""; @@ -153,6 +174,8 @@ 90190872F8D95875E1FFF827 /* Sources */ = { isa = PBXGroup; children = ( + 725C2A9ACD0323F49EFFDFC1 /* AccountDeletionClient.swift */, + FD07129E1ECC6AC505F2FD72 /* AIConsentRecord.swift */, 1CC14C186FE1CB5B19AD1D72 /* AudioBenchmark.swift */, 4E61EF96840670F2F894F970 /* BrainSettings.swift */, 5ABFC6C11146A1540B635EE6 /* ButtonSounds.swift */, @@ -164,15 +187,18 @@ 5D233DBB6F87184A91C13D41 /* MusicalInput.swift */, 5B20A9A8612C57068C67239A /* NativeScreenFixture.swift */, 4BBA0E45689010CB41A6A8FF /* NetworkBenchmark.swift */, + 1ED23B0C39652687E9ECFFAF /* PaymentBuild.swift */, 31AAC74C07F21688CEFF544F /* PieceAudio.swift */, 4D3B156A3435E646EE0E6EEA /* PlaybackCaption.swift */, E97104E13862EE30444D07C9 /* PlaybackWord.swift */, 478EF62AE288CE06A64A3EA4 /* PreviewFormat.swift */, + EF1916E8033C031E046E760C /* PreviewNavigation.swift */, B4F9A906C6B2232BD07BEFE5 /* PromptKeySounds.swift */, CB5E6AF3AF51BAA89BAC231E /* RecordedTranscription.swift */, CC1EFF8BAE7678E320E3DCA0 /* RecordingTrim.swift */, B55FB4AA387E716F9FEE0F83 /* SequenceBenchmark.swift */, B7A25F803FAC121BFECC1AD7 /* SpeechCapture.swift */, + 6D7D7F240AB7ADAA1340FE5C /* StoreCreditDelivery.swift */, E86622F951A1F67F055D69F9 /* StoryCache.swift */, 477B640525BC627AFB40D7A7 /* StoryCardStyle.swift */, 8C42750EF4F7E96146B03585 /* StoryControls.swift */, @@ -188,10 +214,14 @@ 5DF0A171A5771A7A1FD56852 /* VisualCapture.swift */, 060870DF76C6FC078A48A276 /* WhistlegraphAccount.swift */, BA5F188938A81E704BB7F9F9 /* WhistlegraphApp.swift */, + 10BFA0F39F2F514E799291F6 /* WhistlegraphBraincells.swift */, + C534DE3AC6E813F8BDFE5244 /* WhistlegraphDeleteAccount.swift */, 99D9A8C801D3B9D1192D1143 /* WhistlegraphDrawing.swift */, 1BC2C1DC5E036F689EA59561 /* WhistlegraphHeader.swift */, A95FEBB51736F61D93DD1A7A /* WhistlegraphMint.swift */, + 30AAFA5A6A0627C23CA67DA4 /* WhistlegraphPrivacy.swift */, E66B3ADC4D6D837FABC53B8F /* WhistlegraphScreen.swift */, + 2AB3C6140E5D943C3363C353 /* WhistlegraphSource.swift */, D25F85E540016CEBC3A6BC09 /* WhistlegraphTheme.swift */, 5DACE2604B34CCB5EB3EE8AC /* WhistlegraphTV.swift */, E14D13AF43AD0C735B4EA3CB /* WhistlegraphWoodFrame.swift */, @@ -237,6 +267,7 @@ isa = PBXNativeTarget; buildConfigurationList = D078E2B73D1D33A6ECCB8348 /* Build configuration list for PBXNativeTarget "Whistlegraph" */; buildPhases = ( + A546C79E4679B373B57A7AE0 /* Reject internal payment archives */, 08FE7DA642D63309620A8BA1 /* Sources */, 75BBEA53DD88C313F97FB28E /* Resources */, ); @@ -316,17 +347,42 @@ files = ( 275555C74507A6F62B0F037B /* Assets.xcassets in Resources */, AF90A4AEA7F08A553D48A9AB /* Fixtures in Resources */, + 24D22223CEEE7F360CEAEADC /* PrivacyInfo.xcprivacy in Resources */, 5646BD41AA620CC47BF89361 /* Web in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + A546C79E4679B373B57A7AE0 /* Reject internal payment archives */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Reject internal payment archives"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"$CONFIGURATION\" = \"Internal\" ] && [ \"$ACTION\" = \"install\" ]; then\n echo \"error: Internal wallet testing cannot be archived. Use Release for distribution.\"\n exit 1\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 08FE7DA642D63309620A8BA1 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6AB03522A9C86406EB456624 /* AIConsentRecord.swift in Sources */, + 4A8B9BCE5403659FFB71EA65 /* AccountDeletionClient.swift in Sources */, 2F15D5A580A90A18E92D90F1 /* AudioBenchmark.swift in Sources */, 0438F503606B4A37262B7CCC /* BrainSettings.swift in Sources */, 2F01E350A7FF55359B002344 /* ButtonSounds.swift in Sources */, @@ -339,15 +395,18 @@ B35A82E6CD62E5D1D9460EE3 /* NativeScreenFixture.swift in Sources */, 77EC5FCFB7F9BF4A6BD4F94B /* NativeSignIn.swift in Sources */, A570F13C0C8CA571474ED406 /* NetworkBenchmark.swift in Sources */, + E9F24EB7F859B4C869009F26 /* PaymentBuild.swift in Sources */, 9E961DFF3A1C8C9BD8E57866 /* PieceAudio.swift in Sources */, D9E54D2F66B9FC35483A8F45 /* PlaybackCaption.swift in Sources */, 77291EEE768914D5F5EDB84B /* PlaybackWord.swift in Sources */, 938427295F53A8EC1E70C0A9 /* PreviewFormat.swift in Sources */, + 09EF064DE749131E69700C96 /* PreviewNavigation.swift in Sources */, A028C6EA0D6572E890932C2D /* PromptKeySounds.swift in Sources */, E81F2B275AC7E65EC667E67F /* RecordedTranscription.swift in Sources */, 10C0A16D4803EC60EAC29531 /* RecordingTrim.swift in Sources */, C4390906B03E8E8666BC54A3 /* SequenceBenchmark.swift in Sources */, D3D16A0333764D0E5C3B620D /* SpeechCapture.swift in Sources */, + 9BCDA054A876F147E2022B66 /* StoreCreditDelivery.swift in Sources */, 7C8A0DCC5322399983CC8E43 /* StoryCache.swift in Sources */, 603B85FC4E28D17FF1E5F457 /* StoryCardStyle.swift in Sources */, 3CF6178F7BA1702ADFA457B7 /* StoryControls.swift in Sources */, @@ -363,10 +422,14 @@ 749AC25AE02AAE4BCDA5683A /* VisualCapture.swift in Sources */, AFDE98CF6460438CE2205674 /* WhistlegraphAccount.swift in Sources */, 26D07334E2DEBA6705671BB0 /* WhistlegraphApp.swift in Sources */, + 55A7061DE3B5F747C7BE96CE /* WhistlegraphBraincells.swift in Sources */, + 08B1D8E0B17FC2AD2643193B /* WhistlegraphDeleteAccount.swift in Sources */, FAC2911EFFCA887527D2415F /* WhistlegraphDrawing.swift in Sources */, 73873BDDC483417E2F6E1143 /* WhistlegraphHeader.swift in Sources */, 63AC2EF656BB908FC6B195DA /* WhistlegraphMint.swift in Sources */, + 6122FB433EC952206856DEBD /* WhistlegraphPrivacy.swift in Sources */, 4B35B066F49BDD9BD8B90B5A /* WhistlegraphScreen.swift in Sources */, + 2D8D89A7A8C4657353319EBE /* WhistlegraphSource.swift in Sources */, 561DCBD95EB8AECF60476DE7 /* WhistlegraphTV.swift in Sources */, 41FC24FA4CF0D6C4D0C9E7C3 /* WhistlegraphTheme.swift in Sources */, 9E5F0B7DC1CB3E9F4FBE9B90 /* WhistlegraphWoodFrame.swift in Sources */, @@ -397,6 +460,41 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 705782A745A264DF1E9255C5 /* Internal */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.walkieware.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Whistlegraph; + }; + name = Internal; + }; + 9A5BCF9CC2FE71CE97306D40 /* Internal */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.walkieware; + PRODUCT_NAME = Whistlegraph; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Internal; + }; A4F9F566CE43BDCE24ADFC22 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -484,7 +582,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 106; + CURRENT_PROJECT_VERSION = 107; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -569,7 +667,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 106; + CURRENT_PROJECT_VERSION = 107; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; @@ -594,6 +692,73 @@ }; name = Release; }; + F3D994D2DE10605B32F33A2D /* Internal */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 107; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = FB5948YR3S; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG WHISTLEGRAPH_INTERNAL_PAYMENTS"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Internal; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -601,6 +766,7 @@ isa = XCConfigurationList; buildConfigurations = ( BCA778403189480DF26581DD /* Debug */, + F3D994D2DE10605B32F33A2D /* Internal */, E9F6ECF0F5885FE9BAC20620 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -610,6 +776,7 @@ isa = XCConfigurationList; buildConfigurations = ( AD4B90D6D3584CAD888031A3 /* Debug */, + 705782A745A264DF1E9255C5 /* Internal */, A9A9486226BC03219DE1297E /* Release */, ); defaultConfigurationIsVisible = 0; @@ -619,6 +786,7 @@ isa = XCConfigurationList; buildConfigurations = ( A4F9F566CE43BDCE24ADFC22 /* Debug */, + 9A5BCF9CC2FE71CE97306D40 /* Internal */, DF6FB2365BDDD57C0133CB5D /* Release */, ); defaultConfigurationIsVisible = 0; diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/xcshareddata/xcschemes/WhistlegraphInternal.xcscheme b/apple/whistlegraph/Whistlegraph.xcodeproj/xcshareddata/xcschemes/WhistlegraphInternal.xcscheme new file mode 100644 index 0000000000..9600a87de0 --- /dev/null +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/xcshareddata/xcschemes/WhistlegraphInternal.xcscheme @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index 7789de896a..a88f888ec8 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -3,13 +3,20 @@ options: bundleIdPrefix: computer.aesthetic deploymentTarget: iOS: "17.0" +configs: + Debug: debug + Internal: debug + Release: release settings: + configs: + Internal: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DEBUG WHISTLEGRAPH_INTERNAL_PAYMENTS" base: DEVELOPMENT_TEAM: FB5948YR3S CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "106" + CURRENT_PROJECT_VERSION: "107" targets: Whistlegraph: type: application @@ -17,6 +24,8 @@ targets: sources: - Sources - ../aesel/Sources/NativeSignIn.swift + - path: Resources/PrivacyInfo.xcprivacy + buildPhase: resources - path: Resources/Assets.xcassets - path: Resources/Fixtures type: folder @@ -32,6 +41,14 @@ targets: TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + preBuildScripts: + - name: Reject internal payment archives + script: | + if [ "$CONFIGURATION" = "Internal" ] && [ "$ACTION" = "install" ]; then + echo "error: Internal wallet testing cannot be archived. Use Release for distribution." + exit 1 + fi + basedOnDependencyAnalysis: false scheme: gatherCoverageData: false @@ -49,3 +66,18 @@ targets: scheme: testTargets: - WhistlegraphUITests + +schemes: + WhistlegraphInternal: + build: + targets: + Whistlegraph: all + WhistlegraphUITests: [test] + run: + config: Internal + test: + config: Internal + targets: + - WhistlegraphUITests + archive: + config: Release diff --git a/apple/whistlegraph/run.sh b/apple/whistlegraph/run.sh index e5bd3b9b8f..91eedb06f5 100755 --- a/apple/whistlegraph/run.sh +++ b/apple/whistlegraph/run.sh @@ -6,19 +6,24 @@ cd "$(dirname "$0")" node bundle.mjs xcodegen generate >/dev/null mode=${1:-device} +configuration=${CONFIGURATION:-Debug} +case "$configuration" in + Debug|Internal) ;; + *) echo 'Use Debug or Internal for local installs; archive Release in Xcode.' >&2; exit 1 ;; +esac if [[ "$mode" == simulator ]]; then derived=${DERIVED:-/tmp/whistlegraph-sim-dd} simulator=${SIMULATOR:-FEB14FE3-7FDA-4D18-BCD6-F06509360BEA} - xcodebuild -project Whistlegraph.xcodeproj -scheme Whistlegraph -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath "$derived" -jobs 2 CODE_SIGNING_ALLOWED=NO build + xcodebuild -project Whistlegraph.xcodeproj -scheme Whistlegraph -configuration "$configuration" -destination 'generic/platform=iOS Simulator' -derivedDataPath "$derived" -jobs 2 CODE_SIGNING_ALLOWED=NO build xcrun simctl boot "$simulator" 2>/dev/null || true - xcrun simctl install "$simulator" "$derived/Build/Products/Debug-iphonesimulator/Whistlegraph.app" + xcrun simctl install "$simulator" "$derived/Build/Products/${configuration}-iphonesimulator/Whistlegraph.app" xcrun simctl launch "$simulator" computer.aesthetic.walkieware elif [[ "$mode" == device ]]; then derived=${DERIVED:-/tmp/whistlegraph-dd} - xcodebuild -project Whistlegraph.xcodeproj -scheme Whistlegraph -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath "$derived" -allowProvisioningUpdates -jobs 2 build + xcodebuild -project Whistlegraph.xcodeproj -scheme Whistlegraph -configuration "$configuration" -destination 'generic/platform=iOS' -derivedDataPath "$derived" -allowProvisioningUpdates -jobs 2 build device=${DEVICE:-$(xcrun devicectl list devices --json-output - 2>/dev/null | python3 -c 'import json,sys; devices=json.load(sys.stdin).get("result",{}).get("devices",[]); matches=[d["identifier"] for d in devices if d.get("hardwareProperties",{}).get("deviceType")=="iPhone" and d.get("hardwareProperties",{}).get("reality")=="physical" and d.get("connectionProperties",{}).get("pairingState")=="paired"]; print(matches[0] if len(matches)==1 else "")')} [[ -n "$device" ]] || { echo 'Select a paired iPhone with DEVICE=.' >&2; exit 1; } - xcrun devicectl device install app --device "$device" "$derived/Build/Products/Debug-iphoneos/Whistlegraph.app" + xcrun devicectl device install app --device "$device" "$derived/Build/Products/${configuration}-iphoneos/Whistlegraph.app" xcrun devicectl device process launch --device "$device" --terminate-existing computer.aesthetic.walkieware else echo 'Usage: ./run.sh device|simulator' >&2; exit 1 -- 2.51.2