From 9efb5b24614f63f7b7cb1575a7d1753fa385e1e6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 16 Sep 2026 15:42:32 -0700 Subject: [PATCH] aesel: an iPhone app whose renderer is SwiftUI and whose thinking is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apple/aesel is a SwiftUI application. Session.swift draws the six notification kinds AcServer already emits — the contract the desktop TUI reads — so the port is a renderer, not a rewrite. easel/src/ac-server.mjs and publish.mjs run unmodified in a WKWebView that is never shown. That hidden webview is the trade, and it is the opposite of Oskiewar's. Its Mac app hands JavaScriptCore a bundled script and drives Metal, because a webview cannot draw a game; main.swift:569 notes it gets no JIT for the trouble. Aesel renders text, and its piece preview must be a WKWebView on every platform anyway, so the thing Oskiewar avoids is something Aesel already needs. A second invisible one buys fetch, streaming getReader(), TextDecoder and ES modules — none of which JSC has, all of which the shared bridge is written against. session.mjs is the headless half both renderers share. app.mjs is not migrated onto it yet; another session is editing that file. Two failures found by running it rather than by reading it: didFinish is not readiness. It fires before the module script evaluates, so globalThis.aesel may not exist and restore() threw "Can't find variable: aesel". The page posts `ready` after installing the global; that is the signal now. publish.mjs sets User-Agent, which is right for Node and forbidden in a webview. Chrome drops it silently — the browser client publishes fine. WebKit leaks it into the CORS preflight, /presigned-upload-url allows only Content-Type, Authorization and X-Requested-With, and the refusal surfaces as "Load failed" with no hint which of three steps failed. session.mjs strips forbidden headers and names the step; publish.mjs keeps identifying itself on the desktop. Verified in the simulator: signed in, ran turns, wrote the piece, published it, and rendered it live in the stage. The model is the weak part — it wrote ink() without destructuring it, so paint threw after the wipe and the preview looked like a plausible solid colour, then rewrote the same broken piece twelve times because nothing reports runtime errors back. main's runtime-feedback.mjs and ac_preview close that loop; this branch's bridge has neither. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 23ca9b24153174c7e9b4d535e36683706479f54d) --- apple/aesel/Aesel.xcodeproj/project.pbxproj | 304 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/xcschemes/Aesel.xcscheme | 95 ++++++ apple/aesel/Info.plist | 44 +++ apple/aesel/README.md | 93 ++++++ apple/aesel/Sources/AeselApp.swift | 47 +++ apple/aesel/Sources/ContentView.swift | 229 +++++++++++++ apple/aesel/Sources/Session.swift | 183 +++++++++++ apple/aesel/Sources/SessionHost.swift | 202 ++++++++++++ apple/aesel/project.yml | 25 ++ apple/aesel/run.sh | 71 ++++ easel/phone/host.html | 110 +++++++ easel/phone/session.mjs | 292 +++++++++++++++++ 13 files changed, 1702 insertions(+) create mode 100644 apple/aesel/Aesel.xcodeproj/project.pbxproj create mode 100644 apple/aesel/Aesel.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 apple/aesel/Aesel.xcodeproj/xcshareddata/xcschemes/Aesel.xcscheme create mode 100644 apple/aesel/Info.plist create mode 100644 apple/aesel/README.md create mode 100644 apple/aesel/Sources/AeselApp.swift create mode 100644 apple/aesel/Sources/ContentView.swift create mode 100644 apple/aesel/Sources/Session.swift create mode 100644 apple/aesel/Sources/SessionHost.swift create mode 100644 apple/aesel/project.yml create mode 100755 apple/aesel/run.sh create mode 100644 easel/phone/host.html create mode 100644 easel/phone/session.mjs diff --git a/apple/aesel/Aesel.xcodeproj/project.pbxproj b/apple/aesel/Aesel.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..76649b3074 --- /dev/null +++ b/apple/aesel/Aesel.xcodeproj/project.pbxproj @@ -0,0 +1,304 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 1F53641C03321161A7665F3B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E152C400C5CE69463B39F /* ContentView.swift */; }; + 2DB3100BC43BD21115A1BA37 /* AeselApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7CE6904A010A2BEC894D13C /* AeselApp.swift */; }; + 3661DCFA19E2594688511C86 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B505C3279E0F8DB5292B86 /* Session.swift */; }; + 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B505C3279E0F8DB5292B86 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; + 22509229E839210D9200B4AA /* Aesel.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Aesel.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 505E152C400C5CE69463B39F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHost.swift; sourceTree = ""; }; + B7CE6904A010A2BEC894D13C /* AeselApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselApp.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 10821474428BA314BB316CD0 /* Sources */ = { + isa = PBXGroup; + children = ( + B7CE6904A010A2BEC894D13C /* AeselApp.swift */, + 505E152C400C5CE69463B39F /* ContentView.swift */, + 13B505C3279E0F8DB5292B86 /* Session.swift */, + B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */, + ); + path = Sources; + sourceTree = ""; + }; + 34716D2929B4074C97E662AE = { + isa = PBXGroup; + children = ( + 10821474428BA314BB316CD0 /* Sources */, + AB4E40D4AB00182B8B99E0D0 /* Products */, + ); + sourceTree = ""; + }; + AB4E40D4AB00182B8B99E0D0 /* Products */ = { + isa = PBXGroup; + children = ( + 22509229E839210D9200B4AA /* Aesel.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 90FA23DF12444BC96C87BB1F /* Aesel */ = { + isa = PBXNativeTarget; + buildConfigurationList = 828F9F6A9A08AA43646DA0CF /* Build configuration list for PBXNativeTarget "Aesel" */; + buildPhases = ( + 637239781D81E75E1E756D3E /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Aesel; + packageProductDependencies = ( + ); + productName = Aesel; + productReference = 22509229E839210D9200B4AA /* Aesel.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4BB171217B34B598A623D582 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 90FA23DF12444BC96C87BB1F = { + DevelopmentTeam = FB5948YR3S; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = C83B996AD0F1EAD487069A8B /* Build configuration list for PBXProject "Aesel" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 34716D2929B4074C97E662AE; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = AB4E40D4AB00182B8B99E0D0 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 90FA23DF12444BC96C87BB1F /* Aesel */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 637239781D81E75E1E756D3E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2DB3100BC43BD21115A1BA37 /* AeselApp.swift in Sources */, + 1F53641C03321161A7665F3B /* ContentView.swift in Sources */, + 3661DCFA19E2594688511C86 /* Session.swift in Sources */, + 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 11A596B586175ABCA3011E13 /* Release */ = { + 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; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = FB5948YR3S; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + 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; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 13BF820BE2C942E43D67556A /* Release */ = { + 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.aesel; + PRODUCT_NAME = Aesel; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 3CBFE2EDA6795FA5DD601B03 /* Debug */ = { + 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; + 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; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + FFE58E959C6E6A865323AB74 /* Debug */ = { + 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.aesel; + PRODUCT_NAME = Aesel; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 828F9F6A9A08AA43646DA0CF /* Build configuration list for PBXNativeTarget "Aesel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFE58E959C6E6A865323AB74 /* Debug */, + 13BF820BE2C942E43D67556A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C83B996AD0F1EAD487069A8B /* Build configuration list for PBXProject "Aesel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3CBFE2EDA6795FA5DD601B03 /* Debug */, + 11A596B586175ABCA3011E13 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 4BB171217B34B598A623D582 /* Project object */; +} diff --git a/apple/aesel/Aesel.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apple/aesel/Aesel.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/apple/aesel/Aesel.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apple/aesel/Aesel.xcodeproj/xcshareddata/xcschemes/Aesel.xcscheme b/apple/aesel/Aesel.xcodeproj/xcshareddata/xcschemes/Aesel.xcscheme new file mode 100644 index 0000000000..db1cd809a1 --- /dev/null +++ b/apple/aesel/Aesel.xcodeproj/xcshareddata/xcschemes/Aesel.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/aesel/Info.plist b/apple/aesel/Info.plist new file mode 100644 index 0000000000..43a0cd528c --- /dev/null +++ b/apple/aesel/Info.plist @@ -0,0 +1,44 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + Aesel + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + UILaunchScreen + + UIColorName + LaunchBackground + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + AeselHostURL + http://localhost:8770/easel/phone/host.html + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Aesel loads its session from a development server on your network. + + diff --git a/apple/aesel/README.md b/apple/aesel/README.md new file mode 100644 index 0000000000..ff0ed74731 --- /dev/null +++ b/apple/aesel/README.md @@ -0,0 +1,93 @@ +# Aesel for iPhone + +A SwiftUI app. The renderer is native; the thinking is not. + +## The division, and why + +Oskiewar's Metal view says the house rule in its own header: *"the engine speaks +the same triangle stream to every host it has ever had — canvas on the web, D3D +on Xbox, Metal here."* One engine, a narrow contract, a renderer per platform. + +Aesel already had that contract before it needed a second renderer. `AcServer` +emits six notification kinds — `turn/started`, `turn/progress`, +`item/agentMessage/delta`, `item/started`, `item/completed`, `turn/completed` — +and the desktop TUI is only one thing that draws them. `Session.swift` is +another. That is the whole port. + +So the agent loop is not rewritten here. `easel/src/ac-server.mjs` and +`easel/src/publish.mjs` run unmodified, inside a `WKWebView` that is never +shown, and SwiftUI draws every pixel the user sees. + +**Why a hidden webview and not JavaScriptCore.** Oskiewar's Mac app hands JSC a +bundled script and drives Metal, and pays for it: JSC is bare ECMAScript, and +`main.swift:569` notes it gets no JIT in a third-party app. That trade is right +for a game, because a webview cannot draw one. Aesel's renderer is text, and its +piece preview has to be a `WKWebView` on every platform regardless — so the +thing Oskiewar was avoiding is something Aesel needs anyway. Hosting the session +in a second, invisible one costs an object nobody sees and buys `fetch`, +streaming `response.body.getReader()`, `TextDecoder` and ES modules, none of +which JSC has and all of which the shared bridge is written against. + +## Running it + +```sh +./run.sh # simulator +./run.sh device # the iPhone plugged into this Mac +./run.sh device "make a bouncing ball" +``` + +The session is served from this Mac by `easel/phone/serve.mjs`, not bundled, so +a JavaScript edit reaches the phone on relaunch with no rebuild. `run.sh` starts +that server if it is not already up and stamps the current LAN address into +`Info.plist` for device builds — the address is discovered rather than committed +because it changes (this Mac moved from a phone hotspot to Wi-Fi in the middle +of the first build). + +`AESEL_ASK` runs one prompt on launch. It waits for the session to report +`restored` rather than sleeping, so it cannot fire before there is a piece. + +## Two things that bite + +**Readiness is not `didFinish`.** The navigation delegate fires when the +document has loaded, which is before the module script has evaluated, so +`globalThis.aesel` may not exist yet — calling into it there raced and threw +`Can't find variable: aesel`. The page posts a `ready` event after installing +that global, and `SessionHost` treats *that* as the signal. + +**Forbidden headers.** `publish.mjs` sets `User-Agent`, correctly, for Node. +Chrome silently drops it; WebKit leaks it into the CORS preflight, and +`/presigned-upload-url` allows only `Content-Type, Authorization, +X-Requested-With`. The preflight is refused and the whole publish returns the +uninformative "Load failed". `session.mjs` strips forbidden headers for the +publish path rather than changing the shared file, which still wants to identify +itself on the desktop. + +## What is not done + +* **Sign-in is a development shortcut.** The host page asks the laptop for its + own `~/.ac-token`. The real flow is `ASWebAuthenticationSession` against the + same Auth0 client the desktop uses (`hi.aesthetic.computer`, client + `LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt`), which needs a callback URL registered in + Auth0 first — that is an account change, not code. +* **The session is not bundled.** Shipping means copying `easel/src`, + `easel/context` and `easel/phone` into the app and loading them over a custom + scheme, the way Oskiewar's `BundleSchemeHandler` serves its offline fallback. +* **It targets this branch's bridge, not `main`'s.** Main has six tools instead + of one, plus runtime feedback and frame capture, and needs `node:fs/promises`, + `node:util`, a PNG encoder and a *synchronous* `createHash('sha256')` that + `SubtleCrypto` cannot provide. Tracking it matters for more than tidiness — + see below. +* **No app icon, no launch asset, no ASC record.** + +## The model is the weak part, and there is a reason + +Measured on the first three turns: every failure was the model writing a piece +that throws, not the client. It wrote `wipe(color, x, y, w, h)` once and +`ink(...)` without destructuring `ink` another time — `paint` threw after the +first call, so the preview showed a solid colour and looked plausible. + +Worse, it then rewrote the same broken piece twelve times and hit the bridge's +round bound, because **nothing told it the piece was erroring.** `main` has +`runtime-feedback.mjs` and an `ac_preview` tool that close exactly that loop. +That is the strongest argument for tracking main, and it is a correctness +argument rather than a housekeeping one. diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift new file mode 100644 index 0000000000..41200993bf --- /dev/null +++ b/apple/aesel/Sources/AeselApp.swift @@ -0,0 +1,47 @@ +import SwiftUI + +@main +struct AeselApp: App { + @State private var session = Session() + @State private var host: SessionHost + @State private var started = false + + init() { + let session = Session() + _session = State(initialValue: session) + _host = State(initialValue: SessionHost(session: session, store: SessionStore())) + } + + var body: some Scene { + WindowGroup { + ContentView(session: session, host: host) + .preferredColorScheme(.dark) + .task { + guard !started else { return } + started = true + host.start(hostURL: Self.hostURL) + } + } + } + + /// Where the shared session is served from. While iterating this is the + /// laptop running `node easel/phone/serve.mjs --token`, so a JS edit + /// reaches the phone on reload with no rebuild. `AeselHostURL` in + /// Info.plist is the knob; a shipped build points at a bundled copy. + /// Order matters: the environment wins so `run.sh` can point a device at + /// whatever address the laptop has today without editing a tracked file. + /// A LAN address is not stable — this machine moved from a phone hotspot + /// to a Wi-Fi network in the middle of building this, and a baked-in IP + /// would have shipped broken. + static var hostURL: URL { + if let text = ProcessInfo.processInfo.environment["AESEL_HOST"], + let url = URL(string: text) { + return url + } + if let text = Bundle.main.object(forInfoDictionaryKey: "AeselHostURL") as? String, + let url = URL(string: text) { + return url + } + return URL(string: "http://localhost:8770/easel/phone/host.html")! + } +} diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift new file mode 100644 index 0000000000..4f703d0df0 --- /dev/null +++ b/apple/aesel/Sources/ContentView.swift @@ -0,0 +1,229 @@ +import SwiftUI +import WebKit + +/// Aesel's palette, taken from the desktop app's window background so the two +/// clients look like one product. +enum Paint { + static let bg = Color(red: 0x46 / 255, green: 0x32 / 255, blue: 0x64 / 255) + static let deep = Color(red: 0x24 / 255, green: 0x1d / 255, blue: 0x35 / 255) + static let ink = Color(red: 0xef / 255, green: 0xe9 / 255, blue: 0xff / 255) + static let dim = Color(red: 0xb3 / 255, green: 0xa4 / 255, blue: 0xcf / 255) + static let rule = Color(red: 0x5e / 255, green: 0x48 / 255, blue: 0x81 / 255) + static let you = Color(red: 0xff / 255, green: 0x4d / 255, blue: 0x88 / 255) + static let ac = Color(red: 0xb5 / 255, green: 0x8c / 255, blue: 0xff / 255) + static let edit = Color(red: 0x6e / 255, green: 0xe7 / 255, blue: 0xa8 / 255) + static let bad = Color(red: 0xff / 255, green: 0x7a / 255, blue: 0x7a / 255) +} + +struct ContentView: View { + let session: Session + let host: SessionHost + + @State private var draft = "" + @FocusState private var writing: Bool + + var body: some View { + VStack(spacing: 0) { + bar + stage + transcript + composer + } + .background(Paint.bg) + .foregroundStyle(Paint.ink) + // The off-screen session host. Zero-sized and hidden, but in the + // hierarchy so WebKit keeps it running. + .background(HostCarrier(host: host).frame(width: 0, height: 0)) + .overlay { if let fatal = session.fatal { failure(fatal) } } + } + + private var bar: some View { + HStack { + Text(session.route.isEmpty ? "Aesel" : session.route) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 12) + Text(session.status) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(colour(session.health)) + } + .font(.system(size: 15, design: .monospaced)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .overlay(alignment: .bottom) { Divider().overlay(Paint.rule) } + } + + /// The piece, live. A webview because the Aesthetic Computer runtime is one + /// on every platform — that constraint is why hosting the session in a + /// second, hidden webview costs nothing. + private var stage: some View { + ZStack { + Paint.deep + if let url = session.previewURL { + PieceView(url: url) + } else { + Text("Your piece appears here, live, as it is written.") + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(Paint.dim) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + } + .aspectRatio(4 / 3, contentMode: .fit) + .frame(maxWidth: .infinity) + .clipped() + .overlay(alignment: .bottom) { Divider().overlay(Paint.rule) } + } + + private var transcript: some View { + ScrollViewReader { scroller in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(session.entries) { entry in + row(entry).id(entry.id) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .onChange(of: session.entries.last?.text) { + guard let last = session.entries.last else { return } + withAnimation(.easeOut(duration: 0.15)) { + scroller.scrollTo(last.id, anchor: .bottom) + } + } + } + .frame(maxHeight: .infinity) + } + + private func row(_ entry: Entry) -> some View { + HStack(alignment: .top, spacing: 8) { + if let tag = tag(entry.kind) { + Text(tag) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(colour(entry.kind)) + .frame(width: 34, alignment: .leading) + } + Text(entry.text) + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(entry.kind == .note || entry.kind == .bad ? colour(entry.kind) : Paint.ink) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var composer: some View { + HStack(alignment: .bottom, spacing: 8) { + TextField("Describe what to make…", text: $draft, axis: .vertical) + .lineLimit(1 ... 6) + .textFieldStyle(.plain) + .font(.system(size: 16, design: .monospaced)) + .padding(10) + .background(Paint.deep, in: RoundedRectangle(cornerRadius: 10)) + .overlay { RoundedRectangle(cornerRadius: 10).stroke(Paint.rule) } + .focused($writing) + .submitLabel(.send) + + Button { + if session.busy { + host.stop() + } else { + send() + } + } label: { + Image(systemName: session.busy ? "stop.fill" : "arrow.right") + .font(.system(size: 18, weight: .semibold)) + .frame(width: 44, height: 44) + .background(session.busy ? Paint.rule : Paint.you, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(.white) + } + .disabled(!session.busy && draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .overlay(alignment: .top) { Divider().overlay(Paint.rule) } + } + + private func send() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + draft = "" + host.ask(text) + } + + private func failure(_ message: String) -> some View { + VStack(spacing: 12) { + Text("Aesel could not reach its session.") + .font(.system(size: 16, weight: .semibold, design: .monospaced)) + Text(message) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(Paint.dim) + .multilineTextAlignment(.center) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Paint.bg) + } + + private func tag(_ kind: Entry.Kind) -> String? { + switch kind { + case .you: return "YOU" + case .ac: return "AC" + case .edit: return "EDIT" + case .bad: return "!" + case .note: return nil + } + } + + private func colour(_ kind: Entry.Kind) -> Color { + switch kind { + case .you: return Paint.you + case .ac: return Paint.ac + case .edit: return Paint.edit + case .bad: return Paint.bad + case .note: return Paint.dim + } + } + + private func colour(_ health: Health) -> Color { + switch health { + case .idle: return Paint.dim + case .working: return Paint.ac + case .live: return Paint.edit + case .failed: return Paint.bad + } + } +} + +/// Puts the hidden session webview in the hierarchy without drawing it. +private struct HostCarrier: UIViewRepresentable { + let host: SessionHost + + func makeUIView(context: Context) -> WKWebView { host.attachable } + func updateUIView(_ view: WKWebView, context: Context) {} +} + +/// The live piece. Scrolling is off because a piece owns its own gestures. +struct PieceView: UIViewRepresentable { + let url: URL + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = [] + let view = WKWebView(frame: .zero, configuration: configuration) + view.scrollView.isScrollEnabled = false + view.scrollView.contentInsetAdjustmentBehavior = .never + view.isOpaque = false + view.backgroundColor = .clear + view.isMultipleTouchEnabled = true + return view + } + + func updateUIView(_ view: WKWebView, context: Context) { + // The fragment changes on every publish, so compare the whole URL: a + // reload is exactly what a new revision wants. + guard view.url?.absoluteString != url.absoluteString else { return } + view.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)) + } +} diff --git a/apple/aesel/Sources/Session.swift b/apple/aesel/Sources/Session.swift new file mode 100644 index 0000000000..a3730e9be7 --- /dev/null +++ b/apple/aesel/Sources/Session.swift @@ -0,0 +1,183 @@ +import Foundation +import Observation + +/// One line in the transcript. The renderer draws these and nothing else, so +/// everything the bridge reports has to become one of them or be dropped on +/// purpose. +struct Entry: Identifiable, Equatable { + enum Kind: Equatable { + case you + case ac + case edit + case note + case bad + } + + let id = UUID() + var kind: Kind + var text: String +} + +/// What the status pill says, and in which colour. +enum Health: Equatable { + case idle + case working + case live + case failed +} + +/// The whole of what SwiftUI observes. It holds no logic about turns, tools or +/// publishing — those live in `easel/phone/session.mjs`, shared with the +/// desktop. This is the projection of that session's event stream into +/// something a view can draw. +@Observable +final class Session { + var entries: [Entry] = [] + var status: String = "starting" + var health: Health = .idle + var route: String = "" + var previewURL: URL? + var busy = false + var signedIn = false + var handle = "" + /// Set when the host page itself could not start — a missing dev server is + /// the usual cause, and a blank screen is a bad way to say so. + var fatal: String? + + /// The `
  • ` currently receiving streamed deltas. Held as an index because + /// `Entry` is a value type and SwiftUI needs the array mutated in place. + private var streamingIndex: Int? + + func reset() { + entries.removeAll() + streamingIndex = nil + } + + func append(_ kind: Entry.Kind, _ text: String) { + entries.append(Entry(kind: kind, text: text)) + streamingIndex = nil + } + + // MARK: - Events from the shared session + + /// Mirrors what `app.mjs` renders in the browser, so the two clients agree + /// on what a turn looks like. + func receive(_ event: [String: Any]) { + guard let type = event["type"] as? String else { return } + switch type { + case "ready", "restored": + signedIn = event["signedIn"] as? Bool ?? false + if type == "restored" { status = signedIn ? "ready" : "signed out" } + + case "signedIn": + signedIn = true + handle = event["handle"] as? String ?? "" + if handle.isEmpty { + append(.note, "Signed in, but this account has no @handle yet. Claim one at aesthetic.computer/handle to publish.") + } + + case "piece": + route = event["route"] as? String ?? "" + + case "preview": + if let text = event["url"] as? String { previewURL = URL(string: text) } + + case "status": + status = event["text"] as? String ?? status + health = Self.health(event["kind"] as? String) + + case "busy": + busy = event["busy"] as? Bool ?? false + + case "you": + append(.you, event["text"] as? String ?? "") + + case "note": + append(.note, event["text"] as? String ?? "") + + case "bad": + append(.bad, event["text"] as? String ?? "") + health = .failed + + case "bridge": + bridge(event) + + default: + // `persist` is handled by the host before it reaches here, and an + // unknown type is ignored rather than drawn, so a new event kind + // cannot garble the transcript. + break + } + } + + /// The six notification kinds `AcServer` emits. This is Aesel's equivalent + /// of Oskiewar's triangle stream: the contract a renderer binds to. + private func bridge(_ event: [String: Any]) { + let method = event["method"] as? String ?? "" + let params = event["params"] as? [String: Any] ?? [:] + + switch method { + case "turn/started": + streamingIndex = nil + status = "thinking" + health = .working + + case "turn/progress": + if let phase = params["phase"] as? String { + status = phase + health = .working + } + + case "item/agentMessage/delta": + let delta = params["delta"] as? String ?? "" + if let index = streamingIndex, entries.indices.contains(index) { + entries[index].text += delta + } else { + entries.append(Entry(kind: .ac, text: delta)) + streamingIndex = entries.count - 1 + } + + case "item/completed": + guard let item = params["item"] as? [String: Any] else { break } + let kind = item["type"] as? String + if kind == "agentMessage" { + // The delta stream already drew it; this is the same text + // arriving whole, so only draw it if nothing streamed. + if streamingIndex == nil { append(.ac, item["text"] as? String ?? "") } + streamingIndex = nil + } else if kind == "fileChange" { + let state = item["status"] as? String ?? "written" + append(state.hasPrefix("failed") ? .bad : .edit, state) + } + + case "turn/completed": + streamingIndex = nil + let turn = params["turn"] as? [String: Any] ?? [:] + if let error = turn["error"] as? [String: Any], + let message = error["message"] as? String { + append(.bad, message) + status = "failed" + health = .failed + } else if turn["status"] as? String == "interrupted" { + append(.note, "Stopped.") + status = "stopped" + health = .idle + } else { + status = "ready" + health = .idle + } + + default: + break + } + } + + private static func health(_ kind: String?) -> Health { + switch kind { + case "working": return .working + case "live": return .live + case "failed": return .failed + default: return .idle + } + } +} diff --git a/apple/aesel/Sources/SessionHost.swift b/apple/aesel/Sources/SessionHost.swift new file mode 100644 index 0000000000..99fd0f5627 --- /dev/null +++ b/apple/aesel/Sources/SessionHost.swift @@ -0,0 +1,202 @@ +import Foundation +import WebKit + +/// Runs the shared JavaScript session in a WKWebView that is never shown. +/// +/// The session is `easel/phone/session.mjs`, which builds `AcServer` out of +/// `easel/src` unmodified. Hosting it here rather than porting it to Swift is +/// the point: the agent loop, the twelve-round bound, the tool contract and the +/// guides stay one implementation shared with the desktop, and SwiftUI draws +/// the result. +/// +/// The webview is what supplies the web platform the bridge is written +/// against — `fetch`, streaming `response.body.getReader()`, `TextDecoder` and +/// ES modules. JavaScriptCore has none of those. +@MainActor +final class SessionHost: NSObject { + private var webView: WKWebView! + private let session: Session + private let store: SessionStore + /// Calls that arrive before the page finishes loading. Without this, a + /// sign-in completing during launch is silently dropped. + private var pending: [String] = [] + private var loaded = false + /// `AESEL_ASK`, held until the session reports it has restored. + private var openingPrompt = ProcessInfo.processInfo.environment["AESEL_ASK"].flatMap { + $0.isEmpty ? nil : $0 + } + + init(session: Session, store: SessionStore) { + self.session = session + self.store = store + super.init() + + let controller = WKUserContentController() + controller.add(self, name: "aesel") + + // Seeded before any module runs, so `session.mjs` can restore a piece + // and a token on its first tick rather than after a round trip. + if let seed = store.seedJSON() { + controller.addUserScript( + WKUserScript( + source: "globalThis.__aeselSeed = \(seed);", + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + ) + } + + let configuration = WKWebViewConfiguration() + configuration.userContentController = controller + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + + webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = self + webView.isHidden = true + } + + /// The view has to be in the hierarchy or WebKit may not schedule it. It + /// stays hidden and zero-sized; nothing is ever drawn from it. + var attachable: WKWebView { webView } + + func start(hostURL: URL) { + webView.load(URLRequest(url: hostURL, cachePolicy: .reloadIgnoringLocalCacheData)) + } + + // MARK: - Swift → JS + + // Every call is `void`-ed. These functions are async, and handing + // WKWebView a Promise earns "JavaScript execution returned a result of an + // unsupported type" — a harmless error that reads exactly like a real one. + func ask(_ text: String) { call("void aesel.ask(\(quote(text)));") } + func stop() { call("void aesel.stop();") } + func newPiece() { call("void aesel.newPiece();") } + func adopt(token: String) { call("void aesel.adoptToken(\(quote(token)));") } + func restore() { call("void aesel.restore();") } + + private func call(_ javascript: String) { + guard loaded else { + pending.append(javascript) + return + } + webView.evaluateJavaScript(javascript) { _, error in + if let error { NSLog("[aesel] %@ failed: %@", javascript, String(describing: error)) } + } + } + + /// JSON is the only string literal both languages agree on exactly, so the + /// argument is encoded rather than escaped by hand. + private func quote(_ text: String) -> String { + let data = try? JSONSerialization.data(withJSONObject: [text], options: []) + guard let data, var encoded = String(data: data, encoding: .utf8) else { return "\"\"" } + encoded.removeFirst() + encoded.removeLast() + return encoded + } +} + +extension SessionHost: WKScriptMessageHandler { + // WebKit delivers these on the main thread already, so this assumes the + // isolation it is given rather than hopping through a Task. That is not + // only tidier: a Task per message could reorder them, and the transcript + // is a stream of deltas where order is the meaning. + nonisolated func userContentController( + _ controller: WKUserContentController, + didReceive message: WKScriptMessage + ) { + MainActor.assumeIsolated { + guard let body = message.body as? [String: Any] else { + NSLog("[aesel] message was not a dictionary: \(message.body)") + return + } + let type = body["type"] as? String + NSLog("[aesel] event \(type ?? "?")") + + // `didFinish` is not readiness. It fires when the document has + // loaded, which is before the module script has evaluated, so + // `globalThis.aesel` may not exist yet — calling restore() there + // raced and threw "Can't find variable: aesel". The page posts + // `ready` after it has installed that global, so that is the + // signal, and the queue drains against a page that can answer. + if type == "ready" { + loaded = true + let queued = pending + pending.removeAll() + for javascript in queued { call(javascript) } + restore() + return + } + + // The smoke-test prompt waits for a restored session rather than a + // fixed delay, so it cannot run before there is a piece to write. + if type == "restored", let opening = openingPrompt { + openingPrompt = nil + ask(opening) + } + // Persistence is the host's job; everything else is the renderer's. + if body["type"] as? String == "persist", + let key = body["key"] as? String, + let value = body["value"] as? String { + store.write(key: key, value: value) + return + } + session.receive(body) + } + } +} + +extension SessionHost: WKNavigationDelegate { + nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + // Deliberately does not mark the host ready; see the `ready` event. + MainActor.assumeIsolated { NSLog("[aesel] host page loaded") } + } + + nonisolated func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + MainActor.assumeIsolated { session.fatal = error.localizedDescription } + } + + nonisolated func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + MainActor.assumeIsolated { session.fatal = error.localizedDescription } + } +} + +/// A session on disk. One JSON file in Documents, which is the container iOS +/// actually gives an app — there is no ~/.local/share to keep a piece in. +final class SessionStore { + private let url: URL + private var values: [String: String] + + init() { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + url = documents.appendingPathComponent("session.json") + if let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) { + values = decoded + } else { + values = [:] + } + } + + func write(key: String, value: String) { + values[key] = value + guard let data = try? JSONEncoder().encode(values) else { return } + // Atomic so a crash mid-write cannot leave a session that parses as + // half a piece. + try? data.write(to: url, options: .atomic) + } + + func seedJSON() -> String? { + guard !values.isEmpty, + let data = try? JSONSerialization.data(withJSONObject: values, options: []), + let text = String(data: data, encoding: .utf8) else { return nil } + return text + } +} diff --git a/apple/aesel/project.yml b/apple/aesel/project.yml new file mode 100644 index 0000000000..33602b2d1e --- /dev/null +++ b/apple/aesel/project.yml @@ -0,0 +1,25 @@ +name: Aesel +options: + bundleIdPrefix: computer.aesthetic + deploymentTarget: + iOS: "17.0" +settings: + base: + DEVELOPMENT_TEAM: FB5948YR3S + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: "5.0" +targets: + Aesel: + type: application + platform: iOS + sources: + - Sources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: computer.aesthetic.aesel + PRODUCT_NAME: Aesel + INFOPLIST_FILE: Info.plist + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + scheme: + gatherCoverageData: false diff --git a/apple/aesel/run.sh b/apple/aesel/run.sh new file mode 100755 index 0000000000..bff42c707d --- /dev/null +++ b/apple/aesel/run.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Build Aesel and put it somewhere you can use it. +# +# ./run.sh → the simulator +# ./run.sh device → the iPhone plugged into this Mac +# ./run.sh device "ask…" → same, running one prompt on launch +# +# The app loads its session from `easel/phone/serve.mjs` on this machine rather +# than from a bundled copy, so a JavaScript edit reaches the phone on relaunch +# with no rebuild. That address is a LAN IP, and a LAN IP is not stable — this +# Mac moved from a phone hotspot to Wi-Fi mid-session — so it is discovered at +# build time rather than committed. +set -euo pipefail + +cd "$(dirname "$0")" +REPO=$(cd ../.. && pwd) +MODE=${1:-simulator} +ASK=${2:-} +PORT=8770 +DERIVED=${DERIVED:-/tmp/aesel-dd} + +lan() { ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1; } + +if ! curl -fsS -o /dev/null "http://localhost:$PORT/easel/phone/host.html" 2>/dev/null; then + echo "starting the session server…" + (cd "$REPO" && nohup node easel/phone/serve.mjs --port "$PORT" --token >/tmp/aesel-serve.log 2>&1 &) + sleep 2 +fi + +xcodegen generate >/dev/null + +if [ "$MODE" = "device" ]; then + HOST="http://$(lan):$PORT/easel/phone/host.html" + echo "session → $HOST" + # The device has to reach this Mac, so the address is stamped into the built + # Info.plist. The simulator shares the Mac's network and can just use + # localhost, which is why only this branch rewrites the plist. + /usr/libexec/PlistBuddy -c "Set :AeselHostURL $HOST" Info.plist + + xcodebuild -project Aesel.xcodeproj -scheme Aesel \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$DERIVED" \ + -allowProvisioningUpdates build + + APP="$DERIVED/Build/Products/Debug-iphoneos/Aesel.app" + DEVICE=$(xcrun devicectl list devices 2>/dev/null | awk '/connected/ {print $(NF-1); exit}') + if [ -z "$DEVICE" ]; then + echo + echo "Built, but no device is connected. Plug the iPhone in, trust this Mac, and re-run." + echo "App: $APP" + exit 1 + fi + xcrun devicectl device install app --device "$DEVICE" "$APP" + xcrun devicectl device process launch --device "$DEVICE" computer.aesthetic.aesel + exit 0 +fi + +xcodebuild -project Aesel.xcodeproj -scheme Aesel \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath "$DERIVED" build + +xcrun simctl boot "iPhone 17 Pro" 2>/dev/null || true +open -a Simulator +xcrun simctl install booted "$DERIVED/Build/Products/Debug-iphonesimulator/Aesel.app" +xcrun simctl terminate booted computer.aesthetic.aesel 2>/dev/null || true +if [ -n "$ASK" ]; then + SIMCTL_CHILD_AESEL_ASK="$ASK" xcrun simctl launch booted computer.aesthetic.aesel +else + xcrun simctl launch booted computer.aesthetic.aesel +fi diff --git a/easel/phone/host.html b/easel/phone/host.html new file mode 100644 index 0000000000..ecc18ea8fb --- /dev/null +++ b/easel/phone/host.html @@ -0,0 +1,110 @@ + + + + + Aesel session host + + + + + + + + diff --git a/easel/phone/session.mjs b/easel/phone/session.mjs new file mode 100644 index 0000000000..c746d397d8 --- /dev/null +++ b/easel/phone/session.mjs @@ -0,0 +1,292 @@ +// The headless half of a phone session: everything that is not a renderer. +// +// `app.mjs` draws the browser UI and `apple/aesel` draws a SwiftUI one. Both +// want the same things to happen — mount a piece, run a turn through the shared +// bridge, publish what the model writes, report progress — so that lives here +// and the hosts only render. +// +// This is the same division Oskiewar makes. Its engine emits a triangle stream +// and canvas, D3D and Metal each draw it; `MetalSceneView.swift` says so in its +// header. Aesel's equivalent of that stream already existed before anyone +// needed a second renderer: `AcServer` emits six notification kinds, and the +// desktop TUI is just one thing that reads them. A SwiftUI view is another. +// +// Nothing here touches the DOM, so it runs the same in a visible page and in +// the off-screen WKWebView the iOS app keeps for exactly this purpose. + +import { AcServer, DEFAULT_AC_MODEL } from "/easel/src/ac-server.mjs"; +import { publishPiece } from "/easel/src/publish.mjs"; +import * as vfs from "/easel/phone/shim/fs.mjs"; + +export const SITE = "https://aesthetic.computer"; +export const AUTH_DOMAIN = "hi.aesthetic.computer"; + +// Fetched rather than bundled, at the paths the bridge's own `bundledContext()` +// builds, so editing a guide reaches the phone on reload. +const GUIDES = [ + "/easel/context/pieces.md", + "/easel/context/screen.md", + "/easel/context/hand.md", + "/easel/context/kidlisp.md", +]; + +const STARTER = `// A new piece. +export function paint({ wipe }) { + wipe("purple"); +} +`; + +// A slug a person can say out loud. +export function freshSlug() { + const vowels = "aeiou"; + const consonants = "bcdfghjklmnprstvwz"; + let out = ""; + for (let i = 0; i < 3; i += 1) { + out += consonants[Math.floor(Math.random() * consonants.length)]; + out += vowels[Math.floor(Math.random() * vowels.length)]; + } + return out; +} + +// `publish.mjs` sets a `User-Agent` header, which is right for Node and wrong +// in a webview: it is a forbidden header name, so a browser is supposed to drop +// it. Chrome does, silently — the browser client publishes fine. WebKit leaks +// it into the CORS preflight instead, and `/presigned-upload-url` answers +// `Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With` +// with no `user-agent`, so the preflight is refused and the whole publish comes +// back as the uninformative "Load failed". +// +// Stripping it here rather than in `publish.mjs` keeps the shared file correct +// for the desktop, which genuinely wants to identify itself. +const FORBIDDEN = ["user-agent"]; + +function browserFetch(input, init = {}) { + if (!init.headers) return fetch(input, init); + const headers = {}; + for (const [name, value] of Object.entries(init.headers)) { + if (FORBIDDEN.includes(name.toLowerCase())) continue; + headers[name] = value; + } + return fetch(input, { ...init, headers }); +} + +// Storage is injected because the two hosts keep a session in different places +// — localStorage in a browser, the app's Documents container on iOS — and that +// is the only thing about persistence they disagree on. +const memoryStore = () => { + const map = new Map(); + return { + get: (key) => map.get(key) ?? null, + set: (key, value) => void map.set(key, value), + }; +}; + +export function createSession({ storage = memoryStore(), emit = () => {} } = {}) { + const state = { + token: "", + handle: "", + slug: "", + file: "", + server: null, + busy: false, + publishing: null, + dirty: false, + published: false, + }; + + const read = () => { + try { + return JSON.parse(storage.get("session") || "{}"); + } catch { + return {}; + } + }; + + const write = (patch) => { + try { + storage.set("session", JSON.stringify({ ...read(), ...patch })); + } catch { + // A full or unavailable store should cost persistence, not the session. + } + }; + + // Every notification the hosts render flows through here, so a host never + // has to know whether something came from the bridge or from this file. + const say = (type, payload = {}) => emit({ type, ...payload }); + + function route() { + return state.handle ? `@${state.handle}/${state.slug}` : state.slug; + } + + function pieceUrl() { + if (!state.handle) return ""; + // Cache-busted: the URL is stable and the bytes behind it are not. + return `${SITE}/@${state.handle}/${state.slug}?nolabel=true&nogap=true#${Date.now()}`; + } + + function mountPiece(slug, source) { + state.slug = slug; + state.file = `/piece/${slug}.mjs`; + state.server = null; // a new piece is a new conversation + vfs.mount(state.file, source); + write({ slug, source }); + say("piece", { route: route(), slug, source }); + } + + function onWritten(path, source) { + if (path !== state.file) return; + write({ slug: state.slug, source }); + state.dirty = true; + say("source", { source }); + void publish(); + } + + async function publish() { + if (state.publishing) return state.publishing; + if (!state.handle) { + say("note", { text: "Not published — this account has no @handle yet." }); + return; + } + state.dirty = false; + let lastStep = "starting"; + state.publishing = (async () => { + try { + say("status", { text: "publishing", kind: "working" }); + await publishPiece({ + file: state.file, + slug: state.slug, + cwd: "/piece", + site: SITE, + // publish.mjs wants an AcSession and only ever reads these three. + session: { handle: state.handle, signedIn: true, token: async () => state.token }, + fetch: browserFetch, + // Named steps, because "Publish failed: Load failed" does not say + // whether the grant, the upload or the verify was the thing that + // could not load, and those fail for different reasons. + onStep: (step) => { + lastStep = step; + say("status", { text: step, kind: "working" }); + }, + }); + state.published = true; + write({ published: true }); + say("status", { text: "live", kind: "live" }); + say("preview", { url: pieceUrl() }); + } catch (error) { + say("bad", { text: `Publish failed at "${lastStep}": ${error.message}` }); + say("status", { text: "not published", kind: "failed" }); + } finally { + state.publishing = null; + if (state.dirty) void publish(); // a write arrived mid-upload + } + })(); + return state.publishing; + } + + function buildServer() { + const server = new AcServer({ + cwd: "/piece", + model: DEFAULT_AC_MODEL, + piece: { file: state.file }, + // The bridge awaits `token()` per turn so a desktop session can refresh + // a stale one mid-conversation; the phone has nothing to refresh yet. + token: async () => state.token, + site: SITE, + // Stored and called as `this.fetch(…)`. Node tolerates that; a browser + // throws "Illegal invocation" unless window.fetch is bound to window. + fetch: globalThis.fetch.bind(globalThis), + }); + server.on("notification", ({ method, params }) => say("bridge", { method, params })); + return server; + } + + async function ask(text) { + if (!text.trim() || state.busy) return; + say("you", { text }); + state.busy = true; + say("busy", { busy: true }); + try { + if (!state.server) state.server = buildServer(); + await state.server.startTurn(text); + } catch (error) { + say("bad", { text: error.message }); + say("status", { text: "failed", kind: "failed" }); + } finally { + state.busy = false; + say("busy", { busy: false }); + } + } + + function stop() { + state.server?.interrupt?.(); + state.server?.controller?.abort?.(); + } + + // 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(() => ({})); + return String(data?.handle || "").replace(/^@/, ""); + } + + async function adoptToken(token) { + const handle = await resolveHandle(token); + state.token = token; + state.handle = handle; + write({ token, handle }); + say("signedIn", { handle }); + return handle; + } + + // Resolves to true when a stored session was still good. + async function restore() { + const saved = read(); + if (!saved.token) return false; + try { + state.token = saved.token; + state.handle = await resolveHandle(saved.token); + write({ handle: state.handle }); + say("signedIn", { handle: state.handle }); + return true; + } catch { + return false; + } + } + + async function open() { + const saved = read(); + mountPiece(saved.slug || freshSlug(), saved.source || STARTER); + state.published = Boolean(saved.published && saved.slug === state.slug); + if (state.handle && state.published) say("preview", { url: pieceUrl() }); + say("status", { text: "ready", kind: "idle" }); + } + + async function begin() { + vfs.setWriteHandler(onWritten); + const missing = await vfs.preload(GUIDES); + if (missing.length) say("note", { text: `Guides missing: ${missing.join(", ")}` }); + say("ready", { signedIn: Boolean(state.token) }); + } + + return { + state, + begin, + restore, + adoptToken, + open, + ask, + stop, + publish, + newPiece: () => mountPiece(freshSlug(), STARTER), + route, + pieceUrl, + }; +} -- 2.51.2