From eb8a7aa275d65bd0902503933cc0295eb5b6db1b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 21 Sep 2026 22:27:34 -0700 Subject: [PATCH] Restore saved Aesel notebooks before network checks and attach previews on readiness --- apple/aesel/README.md | 3 +- apple/aesel/Sources/AeselAutomation.swift | 2 +- apple/aesel/Sources/AeselPieceView.swift | 22 ++++++++++--- apple/aesel/Sources/ContentView.swift | 3 +- apple/aesel/Tests/preview-bridge.test.mjs | 1 + easel/phone/host.html | 3 +- easel/phone/session.mjs | 40 +++++++++++++++-------- easel/test/phone-session.test.mjs | 28 ++++++++++++++++ 8 files changed, 80 insertions(+), 22 deletions(-) diff --git a/apple/aesel/README.md b/apple/aesel/README.md index fd242cc206..43398fa0ce 100644 --- a/apple/aesel/README.md +++ b/apple/aesel/README.md @@ -30,7 +30,8 @@ idle-stream deadline, without replaying paid requests. A speaker beside the titl opens volume when the preview produces audio; mute remains reachable through silence. The sound popover's 30–240 BPM control scales the preview's shared `clock.time()` from the default 120 BPM without restarting the piece. Clock-driven sequences and -visuals follow together. Sync returns to the network clock's epoch grid. JavaScript +visuals follow together. Sync restarts the preview on the network clock's epoch grid, +so a backwards clock reset cannot strand a piece's beat counter. JavaScript wall-clock timers and audio pitch remain unchanged. Native CLI sessions share Electron's piece-first, responsive-layout, network-clock, diff --git a/apple/aesel/Sources/AeselAutomation.swift b/apple/aesel/Sources/AeselAutomation.swift index dda2a4381a..742fc19ec6 100644 --- a/apple/aesel/Sources/AeselAutomation.swift +++ b/apple/aesel/Sources/AeselAutomation.swift @@ -130,7 +130,7 @@ final class AeselAutomation { "oldest": events.first?["sequence"] ?? sequence, "capacity": 256] case "preview": guard let preview else { throw failure("Preview is not mounted") } - let value = try await preview.evaluateJavaScript("JSON.stringify({url:location.href,ready:!!window.preloaded,continuity:window.__aeselContinuity?.inspect?.()??null,flags:Object.fromEntries(new URLSearchParams(location.search)),canvases:[...document.querySelectorAll('canvas')].map(c=>({width:c.width,height:c.height}))})") + let value = try await preview.evaluateJavaScript("JSON.stringify({url:location.href,ready:!!window.preloaded,sourceLength:window.__aeselSource?.length??0,clockControl:typeof window.AC?.setClockRate,clock:window.AC?.clockState??null,continuity:window.__aeselContinuity?.inspect?.()??null,flags:Object.fromEntries(new URLSearchParams(location.search)),canvases:[...document.querySelectorAll('canvas')].map(c=>({width:c.width,height:c.height}))})") return ["inspection": value as? String ?? "{}"] case "capture": let targetName = params["target"] as? String ?? "app" diff --git a/apple/aesel/Sources/AeselPieceView.swift b/apple/aesel/Sources/AeselPieceView.swift index 5d731d8e2c..c95941800a 100644 --- a/apple/aesel/Sources/AeselPieceView.swift +++ b/apple/aesel/Sources/AeselPieceView.swift @@ -77,6 +77,14 @@ final class PiecePreview: NSObject, ObservableObject, WKNavigationDelegate { for (const key of ['preview', 'icon']) if (query.has(key)) flags.set(key, query.get(key)); window.acSEND({type: 'dropped:piece', content: {name: 'aesel-preview', source, search: flags.toString(), isKidLisp: false}}); }; + window.__aeselSyncClock = () => { + window.__aeselTempo = 120; + window.AC?.setClockRate?.(1, true); + // A UTC reset can go backwards. Restart beat counters rather than + // leaving existing pieces waiting for their old future beat. + rendered = ''; + window.__aeselRender(); + }; const becomeReady = () => { if (ready) return; ready = true; @@ -95,7 +103,7 @@ final class PiecePreview: NSObject, ObservableObject, WKNavigationDelegate { clearInterval(poll); } }, 250); - setTimeout(() => clearInterval(poll), 45000); + // Keep listening after the notice: a slow runtime can still recover. })(); """, injectionTime: .atDocumentStart, forMainFrameOnly: true)) if let url = Bundle.main.url(forResource: "preview-continuity", withExtension: "js"), @@ -141,9 +149,14 @@ final class PiecePreview: NSObject, ObservableObject, WKNavigationDelegate { view.scrollView.backgroundColor = color #endif } + func runtimeReady() { + if failure == "The AC runtime did not become ready. Check your internet connection and retry." { failure = nil } + // Runtime readiness can precede navigation completion on a slow network. + updateSource() + } func syncClock() { tempo = 120 - view.evaluateJavaScript("window.AC?.setClockRate?.(1, true);") + view.evaluateJavaScript("window.__aeselSyncClock?.();") } func setAudio(_ active: Bool, source: String) { if self.source == source { hasAudio = active } @@ -175,9 +188,8 @@ private final class PreviewMessages: NSObject, WKScriptMessageHandler { owner?.setBackdrop(rgb) } else if message.name == "previewFailure", let text = message.body as? String { owner?.failure = text - } else if message.name == "previewFailure", let status = message.body as? [String: Bool], status["ready"] == true, - owner?.failure == "The AC runtime did not become ready. Check your internet connection and retry." { - owner?.failure = nil + } else if message.name == "previewFailure", let status = message.body as? [String: Bool], status["ready"] == true { + owner?.runtimeReady() } } } diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index a53f9cf243..f7e23f061e 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -258,7 +258,8 @@ struct ContentView: View { Text("\(Int(preview.tempo)) BPM").font(Paint.font(13)).monospacedDigit() Spacer() Button("Sync") { preview.syncClock() }.font(Paint.font(12)) - .accessibilityLabel("Return to the shared 120 BPM clock") + .accessibilityLabel("Restart on the shared 120 BPM clock") + .help("Restart on the shared 120 BPM beat") } Slider(value: $preview.tempo, in: 30...240, step: 1) .tint(paint.accent).accessibilityLabel("Metronome rate") diff --git a/apple/aesel/Tests/preview-bridge.test.mjs b/apple/aesel/Tests/preview-bridge.test.mjs index 0d1345499d..956f789cc5 100644 --- a/apple/aesel/Tests/preview-bridge.test.mjs +++ b/apple/aesel/Tests/preview-bridge.test.mjs @@ -58,4 +58,5 @@ test('changing tempo reaches the clock bridge without reinjecting or restarting ticks[1]();assert.equal(rates.at(-1),1.5);assert.equal(sent.length,1); window.__aeselTempo=60;window.__aeselRender();assert.equal(rates.at(-1),0.5);assert.equal(sent.length,1); events['ac-clock-state']({detail:{rate:0.5}});assert.deepEqual(confirmed,[0.5]); + window.__aeselSyncClock();assert.equal(window.__aeselTempo,120);assert.equal(rates.at(-1),1);assert.equal(sent.length,2); }); diff --git a/easel/phone/host.html b/easel/phone/host.html index 806f0e38bf..216201c1d1 100644 --- a/easel/phone/host.html +++ b/easel/phone/host.html @@ -108,8 +108,9 @@ await session.open(); }), restore: guard("restore", async () => { - let ok = await session.restore(); + const restoring = session.restore(); await session.open(); + const ok = await restoring; post({ type: "restored", signedIn: ok }); }), }; diff --git a/easel/phone/session.mjs b/easel/phone/session.mjs index b2ffbe31f6..3978edbf65 100644 --- a/easel/phone/session.mjs +++ b/easel/phone/session.mjs @@ -16,7 +16,7 @@ import { AcServer, DEFAULT_AC_MODEL } from "/easel/src/ac-server.mjs"; import { fetchHandleColors, handleCharacterColors } from "/easel/src/handle-colors.mjs"; -import { isTransientNetworkError } from "/easel/src/network.mjs"; +import { isTransientNetworkError, withNetworkDeadline, httpError } from "/easel/src/network.mjs"; import { publishPiece } from "/easel/src/publish.mjs"; import * as vfs from "/easel/phone/shim/fs.mjs"; import { createCredits } from "./credits.mjs"; @@ -117,6 +117,7 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP }; Object.defineProperty(state,"version",{enumerable:true,get:()=>state.revisions.at(-1)?.version ?? 0}); let hostProviders = []; + let accountEpoch = 0; const credits = createCredits({ token: () => state.token, emit, site: SITE }); const read = () => { @@ -582,15 +583,13 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP // Two calls, because a token is worth nothing here without the @handle that // says where a piece goes. async function resolveHandle(token) { - const who = await fetch(`https://${AUTH_DOMAIN}/userinfo`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!who.ok) throw new Error(`Sign-in check failed (HTTP ${who.status}).`); - const user = await who.json(); - const lookup = await fetch(`${SITE}/handle?for=${encodeURIComponent(user.sub)}`, { - headers: { Accept: "application/json" }, - }); - const data = await lookup.json().catch(() => ({})); + const json = (url, headers) => withNetworkDeadline(async signal => { + const response = await fetch(url, {headers, signal}); + if (!response.ok) throw httpError(`Sign-in check failed (HTTP ${response.status}).`, response.status); + return response.json(); + }, {timeoutMs:8000}); + const user = await json(`https://${AUTH_DOMAIN}/userinfo`, {Authorization:`Bearer ${token}`}); + const data = await json(`${SITE}/handle?for=${encodeURIComponent(user.sub)}`, {Accept:"application/json"}); return String(data?.handle || "").replace(/^@/, ""); } @@ -602,7 +601,9 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP } async function adoptToken(token) { + const epoch = ++accountEpoch; const handle = await resolveHandle(token); + if (epoch !== accountEpoch) return ""; state.token = token; state.handle = handle; write({ token, handle }); @@ -615,25 +616,38 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP // Resolves to true when a stored session was still good. async function restore() { const saved = read(); + const epoch = ++accountEpoch; if (!saved.token) return false; + state.token = saved.token; + state.handle = saved.handle || ""; try { - state.token = saved.token; - state.handle = await resolveHandle(saved.token); + const handle = await resolveHandle(saved.token); + if (epoch !== accountEpoch) return false; + state.handle = handle; write({ handle: state.handle }); say("signedIn", { handle: state.handle }); void loadHandleColors(state.handle); void credits.refresh(); return true; - } catch { + } catch (error) { + if (epoch !== accountEpoch) return false; + if (error.status !== 401 && error.status !== 403) { + // A connection failure is not evidence that the saved login expired. + if (state.handle) say("signedIn", {handle:state.handle}); + say("notice", {scope:"account",text:"Connection interrupted. Your notebook is available offline."}); + return Boolean(state.handle); + } state.token = ""; state.handle = ""; write({ token: "", handle: "" }); + say("signedOut"); credits.clear(); return false; } } function signOut() { + accountEpoch++; saveCurrent(); stop(); state.token = ""; diff --git a/easel/test/phone-session.test.mjs b/easel/test/phone-session.test.mjs index c086061b84..ba1d272311 100644 --- a/easel/test/phone-session.test.mjs +++ b/easel/test/phone-session.test.mjs @@ -277,3 +277,31 @@ test('upload recovery is ephemeral, preserves the draft, and never retries autho } } finally {globalThis.fetch=originalFetch;} }); + +test('saved notebook opens while account lookup is pending; logout wins a late response',async()=>{ + const originalFetch=globalThis.fetch;let complete; + const values=new Map([['session',JSON.stringify({token:'test-token',handle:'test',slug:'keep',source:'export function paint(){}'})]]); + globalThis.fetch=()=>new Promise(resolve=>complete=resolve); + try { + const session=createSession({storage:{get:k=>values.get(k),set:(k,v)=>values.set(k,v)}}); + const restoring=session.restore();await session.open(); + assert.equal(session.state.slug,'keep');assert(session.state.id); + session.signOut(); + globalThis.fetch=async()=>Response.json({handle:'test'}); + complete(Response.json({sub:'user'})); + assert.equal(await restoring,false);assert.equal(session.state.token,''); + assert.equal(JSON.parse(values.get('session')).token,''); + }finally{globalThis.fetch=originalFetch;} +}); + +test('transient account failures preserve saved sign-in and stay out of the notebook',async()=>{ + const originalFetch=globalThis.fetch,events=[]; + const values=new Map([['session',JSON.stringify({token:'test-token',handle:'test',slug:'keep',source:'export function paint(){}'})]]); + globalThis.fetch=async()=>{throw new TypeError('Load failed');}; + try{ + const session=createSession({storage:{get:k=>values.get(k),set:(k,v)=>values.set(k,v)},emit:e=>events.push(e)}); + await session.open();assert.equal(await session.restore(),true); + assert.equal(session.state.token,'test-token');assert.equal(JSON.parse(values.get('session')).token,'test-token'); + assert(events.some(e=>e.type==='notice'&&e.scope==='account'));assert(!session.state.transcript.some(e=>e.type==='bad'||e.type==='notice')); + }finally{globalThis.fetch=originalFetch;} +}); -- 2.51.2