Browse / github.com/storytold/vectorcraft / patch
Packaging: iPad app (iOS wrapper around the web build)
Packaging: iPad app (iOS wrapper around the web build) ## What and why An iPad app built from the existing web build, with no Rust changes. `packaging/ios/` wraps `dist/web` in a native shell: a full-screen `WKWebView` that loads the site from a small loopback HTTP server inside the app. - **WebGPU:** `http://localhost` is a secure context, so WebGPU and the async clipboard work, and `.wasm` is served as `application/wasm`. Each app uses a fixed port, so the page's origin (and with it Data Recovery's `localStorage` and preferences) stays the same between launches. - **Save / Export:** the blob downloads from `Services::download` become `WKDownlo
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- andeecollard (imported from pull request #3263, open)
- Base
bc1ae0825264c5ca4b27bd718d6e04f2121b8bb3- Size
- +797 −0 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6dbcbccf9a49edd4f0d7f103
How it went
No other operator has reported using it yet.
Diff
raw · docs/development.md packaging/ios/.gitignore packaging/ios/README.md packaging/ios/Shared/AppDelegate.swift packaging/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json packaging/ios/Shared/Assets.xcassets/Contents.json packaging/ios/Shared/Assets.xcassets/LaunchBackground.colorset/Contents.json packaging/ios/Shared/Info.plist.in packaging/ios/Shared/LocalServer.swift packaging/ios/Shared/WebViewController.swift packaging/ios/apps/designcraft.conf packaging/ios/apps/vectorcraft.conf packaging/ios/build.sh
@@ -1,5 +1,6 @@ # Development +> **Last updated:** 2026-10-20 · **Change:** iPad app (iOS wrapper around the web build). > **Last updated:** 2026-10-14 · **Change:** leaving out the bundled fonts (`bundled-fonts` feature). > **Last updated:** 2026-10-14 · **Change:** document-font registration for all explicitly supplied faces. @@ -45,6 +46,14 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: - **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. - Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter). +### iPad app++`packaging/ios/` wraps the web build in a native iPad app: a full-screen `WKWebView` serving+`dist/web` from `http://localhost` inside the app (a secure context, so WebGPU works), with Save /+Export going to Files and files opened from Files reaching `window.vectorcraft.openFile`. No Rust+changes: pen and touch input are the web build's. `packaging/ios/build.sh vectorcraft --sim` builds+and runs it on a simulator; see [`packaging/ios/README.md`](../packaging/ios/README.md).+ ## Desktop graphics processor The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (`apps/vectorcraft/src/gpu.rs`, eframe's `native_adapter_selector`) and logs every adapter it found and the one it uses.@@ -375,6 +384,7 @@ do not replace expected hashes automatically in response to a download mismatch. | Date | Change | Summary | |---|---|---|+| 2026-10-20 | minor | Web build › iPad app: the iOS wrapper in `packaging/ios/`. | | 2026-10-14 | minor | Document the `bundled-fonts` feature of vectorcraft-text and vectorcraft-engine (Fonts › Leaving out the bundled fonts). | | 2026-10-14 | minor | Document registration of every explicitly supplied craft-fonts face after the existing script-specific prefix; empty input and web selection remain unchanged. | | 2026-10-13 | minor | Document pinned PngSuite files, integrity checks and download budgets (#1729). |new file mode 100644@@ -0,0 +1 @@+build/new file mode 100644@@ -0,0 +1,55 @@+# iPad app (iOS wrapper)++A native iPad shell around the web build: a full-screen `WKWebView` that runs `dist/web` from a+small HTTP server inside the app. The Rust app is unchanged; everything here is packaging. The same+shell also wraps DesignCraft's web build, one config file per app.++```sh+packaging/ios/build.sh vectorcraft --sim # build, run on an iPad simulator+TEAM=ABCDE12345 packaging/ios/build.sh vectorcraft --device # sign, install, launch on a connected iPad+packaging/ios/build.sh vectorcraft # only generate build/vectorcraft/VectorCraft.xcodeproj+```++`<app>` is a file name in `apps/` (`vectorcraft`, `designcraft`). DesignCraft's repo is looked+for beside this one (`../designcraft`; set `CRAFT_ROOT` to change it).++| Variable | Meaning |+|---|---|+| `TEAM` | Apple developer team id, needed for `--device` (Xcode › Settings › Accounts). A free Personal Team works. |+| `BUNDLE_PREFIX` | Bundle id prefix, default `dev.craft` (→ `dev.craft.vectorcraft`). A free team needs one of its own, e.g. `com.yourname`. |+| `SKIP_WEB=1` | Reuse the last `dist/web` instead of running `trunk build --release`. |+| `SIM_DEVICE`, `DEVICE` | Simulator / device id (`xcrun simctl list devices`, `xcrun devicectl list devices`). Default: the first iPad Pro/Air simulator, the first connected iPad. |++Needs: Xcode with the iOS platform, [XcodeGen](https://github.com/yonaskolb/XcodeGen)+(`brew install xcodegen`), and the [web build](../../docs/development.md#web-build) tools (`trunk`,+the `wasm32-unknown-unknown` target). Homebrew's `rust` has no wasm32 standard library: use rustup+(`brew install rustup`; the script puts `/opt/homebrew/opt/rustup/bin` first on `PATH`). The script+uses `/Applications/Xcode.app` when `xcode-select` points at the Command Line Tools.++Everything it generates goes to `packaging/ios/build/` (gitignored): the Xcode project, a copy of+the site, the app icon (copied from `assets/app-icon/<app>-1024.png`, so no asset is duplicated+here), and the derived data.++## How it works++| File | What it does |+|---|---|+| `Shared/LocalServer.swift` | Serves the bundled site on `http://localhost:<port>` (loopback only, GET/HEAD, nothing outside the site folder). `localhost` is a secure context, so WebGPU and the async clipboard work, and `.wasm` goes out as `application/wasm`. Each app has a fixed port (`CraftServerPort`), so the page's origin, and with it Data Recovery's `localStorage` and preferences, survives relaunches; if the port is taken it falls back to any free one. |+| `Shared/WebViewController.swift` | The web view. Save / Export downloads (blob URLs from `Services::download`) become `WKDownload`s, then a "Save to Files" sheet. Files opened from the Files app, or shared to the app, go to `window.<CraftOpenFileAPI>.openFile(name, bytes)` (VectorCraft's page API, `apps/vectorcraft-web/src/api.rs`), or, for an app without one, arrive as a file dropped on the canvas (eframe's `dropped_files`). It also adds the touch CSS VectorCraft's `index.html` already has (`touch-action: none` on the canvas, no selection or callouts), reloads the page if WebKit kills it for memory (Data Recovery then offers the unsaved work), and opens links to other sites in Safari. Debug builds forward the page's console to the app's log (`xcrun simctl launch --console-pty …`) and make the page inspectable from Safari's Develop menu. |+| `Shared/Info.plist.in` | The template build.sh fills from `apps/<app>.conf`: name, port, page API, the app's own document type (exported) and the file types it opens. iPad only, all orientations, files visible in Files › On My iPad. |+| `apps/<app>.conf` | Per app: `NAME`, `PORT`, `JS_API` (empty: open files by drop), `DOC_EXTS`, `IMPORT_UTIS`. |++## Notes++- Apple Pencil pressure, hover and palm rejection come from the web app itself+ (`apps/vectorcraft-web/src/pencil.rs`), so VectorCraft gets them in the wrapper as in Safari.+ DesignCraft doesn't have that module yet: there a pen acts like a finger.+- iOS gives a web view less memory than a native app, and wasm32 can't address more than 4 GB:+ very large documents can make WebKit reload the page.+- With a free Personal Team, an installed app stops opening after 7 days (install it again) and+ a device holds at most 3 such apps. The first install needs Settings › General › VPN & Device+ Management › trust the developer, and iPadOS 16+ needs Developer Mode (Settings › Privacy &+ Security).+- Tested on the iPad Pro 11-inch (M4) simulator (iOS 18.1): all three apps start, take touch+ input, create or open documents and draw. Installed and used on an iPad Pro 12.9-inch (4th+ generation). Not yet tested: saving to Files and opening from Files.new file mode 100644@@ -0,0 +1,29 @@+import UIKit++@main+final class AppDelegate: UIResponder, UIApplicationDelegate {+ func application(_ application: UIApplication,+ configurationForConnecting connectingSceneSession: UISceneSession,+ options: UIScene.ConnectionOptions) -> UISceneConfiguration {+ UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role)+ }+}++final class SceneDelegate: UIResponder, UIWindowSceneDelegate {+ var window: UIWindow?+ private let controller = WebViewController()++ func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {+ guard let windowScene = scene as? UIWindowScene else { return }+ let window = UIWindow(windowScene: windowScene)+ window.rootViewController = controller+ window.makeKeyAndVisible()+ self.window = window+ // Launched by opening a document from Files.+ controller.open(connectionOptions.urlContexts.map(\.url))+ }++ func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {+ controller.open(URLContexts.map(\.url))+ }+}new file mode 100644@@ -0,0 +1,4 @@+{+ "images" : [ { "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],+ "info" : { "author" : "xcode", "version" : 1 }+}new file mode 100644@@ -0,0 +1 @@+{ "info" : { "author" : "xcode", "version" : 1 } }new file mode 100644@@ -0,0 +1,5 @@+{+ "colors" : [ { "idiom" : "universal", "color" : { "color-space" : "srgb",+ "components" : { "red" : "0x26", "green" : "0x26", "blue" : "0x26", "alpha" : "1.000" } } } ],+ "info" : { "author" : "xcode", "version" : 1 }+}new file mode 100644@@ -0,0 +1,123 @@+<?xml version="1.0" encoding="UTF-8"?>+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">+<plist version="1.0">+<dict>+ <key>CFBundleDevelopmentRegion</key>+ <string>en</string>+ <key>CFBundleDisplayName</key>+ <string>@APP_NAME@</string>+ <key>CFBundleExecutable</key>+ <string>$(EXECUTABLE_NAME)</string>+ <key>CFBundleIdentifier</key>+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>+ <key>CFBundleInfoDictionaryVersion</key>+ <string>6.0</string>+ <key>CFBundleName</key>+ <string>$(PRODUCT_NAME)</string>+ <key>CFBundlePackageType</key>+ <string>APPL</string>+ <key>CFBundleShortVersionString</key>+ <string>$(MARKETING_VERSION)</string>+ <key>CFBundleVersion</key>+ <string>$(CURRENT_PROJECT_VERSION)</string>+ <key>LSRequiresIPhoneOS</key>+ <true/>+ <key>UILaunchScreen</key>+ <dict>+ <key>UIColorName</key>+ <string>LaunchBackground</string>+ </dict>+ <key>UIApplicationSceneManifest</key>+ <dict>+ <key>UIApplicationSupportsMultipleScenes</key>+ <false/>+ <key>UISceneConfigurations</key>+ <dict>+ <key>UIWindowSceneSessionRoleApplication</key>+ <array>+ <dict>+ <key>UISceneConfigurationName</key>+ <string>Default</string>+ <key>UISceneDelegateClassName</key>+ <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>+ </dict>+ </array>+ </dict>+ </dict>+ <key>UISupportedInterfaceOrientations~ipad</key>+ <array>+ <string>UIInterfaceOrientationPortrait</string>+ <string>UIInterfaceOrientationPortraitUpsideDown</string>+ <string>UIInterfaceOrientationLandscapeLeft</string>+ <string>UIInterfaceOrientationLandscapeRight</string>+ </array>+ <key>UIStatusBarHidden</key>+ <true/>+ <key>UIViewControllerBasedStatusBarAppearance</key>+ <true/>+ <!-- Read by the wrapper: the in-app server's fixed port (keeps the page origin, and its saved+ data, stable) and the page's `window.<name>.openFile` API, if it has one. -->+ <key>CraftServerPort</key>+ <integer>@PORT@</integer>+ <key>CraftOpenFileAPI</key>+ <string>@JS_API@</string>+ <!-- The bundled site is served from http://localhost inside the app (a secure context for WebGPU). -->+ <key>NSAppTransportSecurity</key>+ <dict>+ <key>NSAllowsLocalNetworking</key>+ <true/>+ </dict>+ <!-- Files › On My iPad › the app. -->+ <key>UIFileSharingEnabled</key>+ <true/>+ <key>LSSupportsOpeningDocumentsInPlace</key>+ <false/>+ <key>CFBundleDocumentTypes</key>+ <array>+ <dict>+ <key>CFBundleTypeName</key>+ <string>@APP_NAME@ Document</string>+ <key>CFBundleTypeRole</key>+ <string>Editor</string>+ <key>LSHandlerRank</key>+ <string>Owner</string>+ <key>LSItemContentTypes</key>+ <array>+ <string>$(PRODUCT_BUNDLE_IDENTIFIER).document</string>+ </array>+ </dict>+ <dict>+ <key>CFBundleTypeName</key>+ <string>Other Files</string>+ <key>CFBundleTypeRole</key>+ <string>Editor</string>+ <key>LSHandlerRank</key>+ <string>Alternate</string>+ <key>LSItemContentTypes</key>+ <array>+@IMPORT_UTIS@+ </array>+ </dict>+ </array>+ <key>UTExportedTypeDeclarations</key>+ <array>+ <dict>+ <key>UTTypeIdentifier</key>+ <string>$(PRODUCT_BUNDLE_IDENTIFIER).document</string>+ <key>UTTypeDescription</key>+ <string>@APP_NAME@ Document</string>+ <key>UTTypeConformsTo</key>+ <array>+ <string>public.data</string>+ </array>+ <key>UTTypeTagSpecification</key>+ <dict>+ <key>public.filename-extension</key>+ <array>+ @DOC_EXTS@+ </array>+ </dict>+ </dict>+ </array>+</dict>+</plist>new file mode 100644@@ -0,0 +1,144 @@+import Foundation+import Network++/// A tiny static-file HTTP server for the bundled web build, bound to the loopback interface.+///+/// `http://localhost` is a secure context, so WebGPU and the async clipboard work, and `.wasm` is+/// served as `application/wasm` so it stream-compiles. A fixed port keeps the page's origin (and so+/// its IndexedDB recovery data and preferences) the same from launch to launch.+final class LocalServer {+ /// `CraftServerPort` from Info.plist: each app has its own.+ static let preferredPort: UInt16 = (Bundle.main.object(forInfoDictionaryKey: "CraftServerPort") as? NSNumber)?.uint16Value ?? 47_861++ private let root: URL+ private var listener: NWListener?+ private let queue = DispatchQueue(label: "craft.localserver", attributes: .concurrent)+ private(set) var port: UInt16 = 0++ init(root: URL) {+ self.root = root.standardizedFileURL+ }++ /// Start listening; calls `ready` on the main queue with the port, or nil on failure.+ func start(ready: @escaping (UInt16?) -> Void) {+ start(on: Self.preferredPort, fallback: true, ready: ready)+ }++ private func start(on port: UInt16, fallback: Bool, ready: @escaping (UInt16?) -> Void) {+ let params = NWParameters.tcp+ params.allowLocalEndpointReuse = true+ params.requiredInterfaceType = .loopback+ guard let nwPort = NWEndpoint.Port(rawValue: port),+ let listener = try? NWListener(using: params, on: nwPort) else {+ if fallback { start(on: 0, fallback: false, ready: ready) } else { DispatchQueue.main.async { ready(nil) } }+ return+ }+ self.listener = listener+ var reported = false+ listener.stateUpdateHandler = { [weak self] state in+ guard let self else { return }+ switch state {+ case .ready:+ guard !reported else { return }+ reported = true+ self.port = listener.port?.rawValue ?? port+ DispatchQueue.main.async { ready(self.port) }+ case .failed:+ listener.cancel()+ guard !reported else { return }+ reported = true+ // The preferred port is taken: any free one still works (the origin just differs).+ if fallback { self.start(on: 0, fallback: false, ready: ready) } else { DispatchQueue.main.async { ready(nil) } }+ default:+ break+ }+ }+ listener.newConnectionHandler = { [weak self] connection in+ self?.handle(connection)+ }+ listener.start(queue: queue)+ }++ func stop() {+ listener?.cancel()+ listener = nil+ }++ // MARK: - HTTP++ private func handle(_ connection: NWConnection) {+ connection.start(queue: queue)+ receiveRequest(connection, buffer: Data())+ }++ private func receiveRequest(_ connection: NWConnection, buffer: Data) {+ connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in+ guard let self else { connection.cancel(); return }+ var buffer = buffer+ if let data { buffer.append(data) }+ if let end = buffer.range(of: Data("\r\n\r\n".utf8)) {+ let head = String(decoding: buffer[..<end.lowerBound], as: UTF8.self)+ self.respond(to: head, on: connection)+ } else if error != nil || isComplete || buffer.count > 64 * 1024 {+ connection.cancel()+ } else {+ self.receiveRequest(connection, buffer: buffer)+ }+ }+ }++ private func respond(to head: String, on connection: NWConnection) {+ let requestLine = head.split(separator: "\r\n", maxSplits: 1).first.map(String.init) ?? ""+ let parts = requestLine.split(separator: " ")+ guard parts.count >= 2 else { return send(status: "400 Bad Request", on: connection) }+ let method = String(parts[0])+ guard method == "GET" || method == "HEAD" else { return send(status: "405 Method Not Allowed", on: connection) }++ var path = String(parts[1])+ if let q = path.firstIndex(where: { $0 == "?" || $0 == "#" }) { path = String(path[..<q]) }+ path = path.removingPercentEncoding ?? path+ if path.hasSuffix("/") { path += "index.html" }++ let file = root.appendingPathComponent(String(path.drop(while: { $0 == "/" }))).standardizedFileURL+ // Never serve anything outside the bundled site.+ guard file.path.hasPrefix(root.path + "/"),+ let body = try? Data(contentsOf: file, options: .mappedIfSafe) else {+ return send(status: "404 Not Found", on: connection)+ }++ // Never cached for long: the web view's cache outlives app updates (same origin, fixed port),+ // and files with unhashed names (fonts/, …) would stay stale. Serving over loopback from a+ // mapped file costs next to nothing.+ var header = "HTTP/1.1 200 OK\r\n"+ header += "Content-Type: \(Self.mimeType(for: file.pathExtension))\r\n"+ header += "Content-Length: \(body.count)\r\n"+ header += "Cache-Control: no-cache\r\n"+ header += "X-Content-Type-Options: nosniff\r\n"+ header += "Connection: close\r\n\r\n"+ var out = Data(header.utf8)+ if method == "GET" { out.append(body) }+ connection.send(content: out, completion: .contentProcessed { _ in connection.cancel() })+ }++ private func send(status: String, on connection: NWConnection) {+ let out = Data("HTTP/1.1 \(status)\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8)+ connection.send(content: out, completion: .contentProcessed { _ in connection.cancel() })+ }++ private static func mimeType(for ext: String) -> String {+ switch ext.lowercased() {+ case "html": return "text/html; charset=utf-8"+ case "js", "mjs": return "text/javascript"+ case "wasm": return "application/wasm"+ case "css": return "text/css"+ case "json": return "application/json"+ case "svg": return "image/svg+xml"+ case "png": return "image/png"+ case "ttf": return "font/ttf"+ case "otf": return "font/otf"+ case "woff2": return "font/woff2"+ case "txt": return "text/plain; charset=utf-8"+ default: return "application/octet-stream"+ }+ }+}new file mode 100644@@ -0,0 +1,301 @@+import UIKit+import UniformTypeIdentifiers+import WebKit++/// Full-screen WKWebView running a Craft app's web build (apps/<app>-web) from the in-app server.+///+/// Native glue the browser can't do on its own:+/// - Save / Export: the page's blob downloads become `WKDownload`s, then a "Save to Files" sheet;+/// - files opened from the Files app (or shared to the app) go to `window.<CraftOpenFileAPI>.openFile`+/// when the page has it (VectorCraft), or else arrive as a file dropped on the canvas.+final class WebViewController: UIViewController {+ private var webView: WKWebView!+ private var server: LocalServer?+ private var pendingFiles: [URL] = []+ private var pageLoaded = false+ /// A delivery loop is working through `pendingFiles`; only one runs at a time, so a file isn't+ /// opened twice or dropped from the queue undelivered.+ private var delivering = false+ /// Where each in-flight download is being written.+ private var downloadTargets: [ObjectIdentifier: URL] = [:]+ private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "App"+ private let openFileAPI = Bundle.main.object(forInfoDictionaryKey: "CraftOpenFileAPI") as? String ?? ""++ override var prefersStatusBarHidden: Bool { true }+ override var prefersHomeIndicatorAutoHidden: Bool { true }+ // Let the page see edge swipes (the canvas pans and zooms with them).+ override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { .all }++ override func loadView() {+ let config = WKWebViewConfiguration()+ config.websiteDataStore = .default()+ config.preferences.isElementFullscreenEnabled = true+ config.defaultWebpagePreferences.allowsContentJavaScript = true+ config.allowsInlineMediaPlayback = true+ config.mediaTypesRequiringUserActionForPlayback = []+ // Touch rules VectorCraft's page has and the other apps' pages don't yet: the canvas takes+ // every touch (`touch-action: none`, so WebKit doesn't keep fingers for scrolling or zooming),+ // no overscroll, text selection, callouts or tap highlight.+ let css = """+ html, body { overscroll-behavior: none; -webkit-user-select: none; user-select: none;+ -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }+ canvas { touch-action: none; }+ """+ let inject = "(() => { const s = document.createElement('style'); s.textContent = \(Self.jsString(css)); document.head.appendChild(s); })()"+ config.userContentController.addUserScript(+ WKUserScript(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: true))+ #if DEBUG+ // The page's console and uncaught errors in the app's log (`simctl launch --console`).+ let bridge = """+ (() => {+ const post = (level, args) => { try { webkit.messageHandlers.console.postMessage(level + ": " ++ Array.from(args).map(a => a instanceof Error ? a.stack || String(a) : typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")); } catch (_) {} };+ for (const level of ["log", "info", "warn", "error", "debug"]) {+ const orig = console[level].bind(console);+ console[level] = (...args) => { post(level, args); orig(...args); };+ }+ addEventListener("error", e => post("uncaught", [e.message, e.filename + ":" + e.lineno]));+ addEventListener("unhandledrejection", e => post("unhandled rejection", [e.reason]));+ })()+ """+ config.userContentController.addUserScript(+ WKUserScript(source: bridge, injectionTime: .atDocumentStart, forMainFrameOnly: true))+ config.userContentController.add(ConsoleLogger(), name: "console")+ #endif++ webView = WKWebView(frame: .zero, configuration: config)+ webView.navigationDelegate = self+ webView.uiDelegate = self+ webView.isOpaque = false+ webView.backgroundColor = UIColor(red: 0x26 / 255, green: 0x26 / 255, blue: 0x26 / 255, alpha: 1)+ webView.scrollView.isScrollEnabled = false+ webView.scrollView.bounces = false+ webView.scrollView.contentInsetAdjustmentBehavior = .never+ webView.scrollView.pinchGestureRecognizer?.isEnabled = false+ webView.allowsBackForwardNavigationGestures = false+ webView.allowsLinkPreview = false+ #if DEBUG+ webView.isInspectable = true // Safari › Develop › <iPad> to debug the page+ #endif+ view = webView+ }++ override func viewDidLoad() {+ super.viewDidLoad()+ guard let site = Bundle.main.url(forResource: "web", withExtension: nil),+ FileManager.default.fileExists(atPath: site.appendingPathComponent("index.html").path) else {+ showError("The web build is missing from the app bundle. Run packaging/ios/build.sh <app>, then build again.")+ return+ }+ let server = LocalServer(root: site)+ self.server = server+ server.start { [weak self] port in+ guard let self else { return }+ guard let port, let url = URL(string: "http://localhost:\(port)/index.html") else {+ self.showError("Couldn't start the local web server.")+ return+ }+ self.webView.load(URLRequest(url: url))+ }+ }++ // MARK: - Opening files++ /// Queue `urls` for the page; they open once the app inside it is running.+ func open(_ urls: [URL]) {+ pendingFiles.append(contentsOf: urls)+ if pageLoaded { deliverPendingFiles() }+ }++ /// Starts delivering the queued files, unless a delivery loop is already running (it picks up+ /// files queued since).+ private func deliverPendingFiles() {+ guard !delivering else { return }+ deliverNext(attempt: 0)+ }++ private func deliverNext(attempt: Int) {+ guard let url = pendingFiles.first else {+ delivering = false+ return+ }+ delivering = true+ let scoped = url.startAccessingSecurityScopedResource()+ let data = try? Data(contentsOf: url)+ if scoped { url.stopAccessingSecurityScopedResource() }+ guard let data else {+ pendingFiles.removeFirst()+ showError("Couldn't read \(url.lastPathComponent).")+ deliverNext(attempt: 0)+ return+ }+ let args = (try? JSONSerialization.data(withJSONObject: [url.lastPathComponent, openFileAPI]))+ .flatMap { String(data: $0, encoding: .utf8) } ?? "[\"file\", \"\"]"+ // Returns false until the app inside the page has started (its loading text is still up, or+ // `openFile` refuses the file): retry.+ let js = """+ (() => {+ const [name, api] = \(args);+ const vc = api ? window[api] : null;+ const canvas = document.querySelector("canvas");+ const loading = document.querySelector("[id$='_loading']");+ if (!canvas || (vc ? !vc.openFile : loading)) return false;+ const bin = atob("\(data.base64EncodedString())");+ const bytes = new Uint8Array(bin.length);+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);+ if (vc) return vc.openFile(name, bytes) === true;+ // No API: hand the file over the way a drag and drop onto the canvas does.+ const dt = new DataTransfer();+ dt.items.add(new File([bytes], name));+ const r = canvas.getBoundingClientRect();+ const at = { clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, bubbles: true, cancelable: true, dataTransfer: dt };+ canvas.dispatchEvent(new DragEvent("dragenter", at));+ canvas.dispatchEvent(new DragEvent("dragover", at));+ canvas.dispatchEvent(new DragEvent("drop", at));+ return true;+ })()+ """+ webView.evaluateJavaScript(js) { [weak self] result, _ in+ guard let self else { return }+ if (result as? Bool) == true {+ self.pendingFiles.removeFirst()+ self.deliverNext(attempt: 0)+ } else if attempt < 120 {+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.deliverNext(attempt: attempt + 1) }+ } else {+ // Give up on this file; the rest wait for the next open or page load.+ self.pendingFiles.removeFirst()+ self.delivering = false+ self.showError("\(self.appName) didn't start in time to open \(url.lastPathComponent).")+ }+ }+ }++ // MARK: - Helpers++ /// `s` as a JavaScript string literal.+ private static func jsString(_ s: String) -> String {+ (try? JSONSerialization.data(withJSONObject: [s])).flatMap { String(data: $0, encoding: .utf8) }+ .map { String($0.dropFirst().dropLast()) } ?? "\"\""+ }++ private func showError(_ message: String) {+ let alert = UIAlertController(title: appName, message: message, preferredStyle: .alert)+ alert.addAction(UIAlertAction(title: "OK", style: .default))+ present(alert, animated: true)+ }++ private func exportToFiles(_ file: URL) {+ let picker = UIDocumentPickerViewController(forExporting: [file], asCopy: true)+ picker.shouldShowFileExtensions = true+ present(picker, animated: true)+ }+}++// MARK: - Navigation and downloads++extension WebViewController: WKNavigationDelegate {+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {+ pageLoaded = true+ deliverPendingFiles()+ }++ func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {+ if navigationAction.shouldPerformDownload || navigationAction.request.url?.scheme == "blob" {+ decisionHandler(.download)+ return+ }+ // Links off the app (help pages, licences…) open in Safari.+ if let url = navigationAction.request.url, url.host != "localhost", url.scheme == "http" || url.scheme == "https",+ navigationAction.targetFrame?.isMainFrame ?? true {+ UIApplication.shared.open(url)+ decisionHandler(.cancel)+ return+ }+ decisionHandler(.allow)+ }++ func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,+ decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {+ decisionHandler(navigationResponse.canShowMIMEType ? .allow : .download)+ }++ func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {+ download.delegate = self+ }++ func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {+ download.delegate = self+ }++ func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {+ // Memory pressure killed the page: reload it (Data Recovery offers the unsaved work).+ webView.reload()+ }+}++extension WebViewController: WKDownloadDelegate {+ func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String,+ completionHandler: @escaping (URL?) -> Void) {+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)+ do {+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)+ } catch {+ completionHandler(nil)+ return+ }+ let target = dir.appendingPathComponent(suggestedFilename.isEmpty ? "Untitled" : suggestedFilename)+ downloadTargets[ObjectIdentifier(download)] = target+ completionHandler(target)+ }++ func downloadDidFinish(_ download: WKDownload) {+ guard let file = downloadTargets.removeValue(forKey: ObjectIdentifier(download)) else { return }+ exportToFiles(file)+ }++ func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {+ downloadTargets.removeValue(forKey: ObjectIdentifier(download))+ showError("Save failed: \(error.localizedDescription)")+ }+}++// MARK: - JavaScript dialogs and new windows++extension WebViewController: WKUIDelegate {+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,+ for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {+ if let url = navigationAction.request.url {+ if url.scheme == "blob" {+ webView.load(navigationAction.request) // becomes a download+ } else {+ UIApplication.shared.open(url)+ }+ }+ return nil+ }++ func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,+ initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {+ let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)+ alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler() })+ present(alert, animated: true)+ }++ func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,+ initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {+ let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) })+ alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) })+ present(alert, animated: true)+ }+}++#if DEBUG+private final class ConsoleLogger: NSObject, WKScriptMessageHandler {+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {+ print("[page] \(message.body)")+ }+}+#endifnew file mode 100644@@ -0,0 +1,5 @@+NAME=DesignCraft+PORT=47862+JS_API=+DOC_EXTS="designcraft"+IMPORT_UTIS="com.adobe.pdf public.svg-image public.png public.jpeg" # brand-ok: system type identifiers Apple defines (UTIs)new file mode 100644@@ -0,0 +1,5 @@+NAME=VectorCraft+PORT=47861+JS_API=vectorcraft+DOC_EXTS="vectorcraft drawcraft"+IMPORT_UTIS="public.svg-image com.adobe.pdf com.adobe.encapsulated-postscript com.adobe.illustrator.ai-image public.png public.jpeg com.compuserve.gif org.webmproject.webp public.tiff com.microsoft.bmp" # brand-ok: system type identifiers Apple defines (UTIs)new file mode 100755@@ -0,0 +1,114 @@+#!/usr/bin/env bash+# Build a Craft app's iPad version: a native shell (Shared/) around its web build (apps/<app>-web).+#+# packaging/ios/build.sh <app> # trunk build, then generate build/<app>/<Name>.xcodeproj+# packaging/ios/build.sh <app> --sim # …then build and run on an iPad simulator+# TEAM=ABCDE12345 packaging/ios/build.sh <app> --device # …then sign, install and launch on a connected iPad+#+# <app> is vectorcraft or designcraft (apps/<app>.conf). DesignCraft's repo is looked for+# beside this one (CRAFT_ROOT, default: this repo's parent folder).+# Env: SKIP_WEB=1 reuses the last web build; BUNDLE_PREFIX (default dev.craft) → <prefix>.<app>;+# SIM_DEVICE / DEVICE pick a simulator / device by id.+# Needs: rustup with the wasm32-unknown-unknown target, trunk, xcodegen, Xcode.+set -euo pipefail+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"+ROOT="$(cd "$HERE/../.." && pwd)"+APP="${1:-}"; MODE="${2:-}"+if [ ! -f "$HERE/apps/$APP.conf" ]; then+ apps=""+ for f in "$HERE"/apps/*.conf; do b="${f##*/}"; apps="${apps:+$apps|}${b%.conf}"; done+ echo "usage: $0 <$apps> [--sim|--device]" >&2+ exit 2+fi+# shellcheck source=/dev/null+. "$HERE/apps/$APP.conf"+if [ "$APP" = vectorcraft ]; then REPO="$ROOT"; else REPO="${CRAFT_ROOT:-$(dirname "$ROOT")}/$APP"; fi+[ -d "$REPO/apps/$APP-web" ] || { echo "error: no $REPO/apps/$APP-web (clone storytold/$APP there or set CRAFT_ROOT)" >&2; exit 1; }+BUNDLE_ID="${BUNDLE_PREFIX:-dev.craft}.$APP"++# Homebrew's rust has no wasm32 std; prefer a rustup toolchain when there is one.+[ -d /opt/homebrew/opt/rustup/bin ] && export PATH="/opt/homebrew/opt/rustup/bin:$PATH"+# Use the full Xcode even when xcode-select points at the Command Line Tools.+if ! xcodebuild -version >/dev/null 2>&1 && [ -d /Applications/Xcode.app ]; then+ export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer+fi++if [ "${SKIP_WEB:-}" != 1 ]; then+ (cd "$REPO/apps/$APP-web" && trunk build --release)+fi+[ -f "$REPO/dist/web/index.html" ] || { echo "error: no $REPO/dist/web/index.html" >&2; exit 1; }++# Assemble build/<app>/: the shared sources, this app's Info.plist, icon and web site.+OUT="$HERE/build/$APP"+SRC="$OUT/$NAME"+rm -rf "$SRC" "$OUT/web" "$OUT/$NAME.xcodeproj"+mkdir -p "$SRC"+/bin/cp -f "$HERE"/Shared/*.swift "$SRC/"+/bin/cp -R "$HERE/Shared/Assets.xcassets" "$SRC/"+/bin/cp -f "$REPO/assets/app-icon/$APP-1024.png" "$SRC/Assets.xcassets/AppIcon.appiconset/AppIcon.png"+/bin/cp -R "$REPO/dist/web" "$OUT/web"+rm -f "$OUT/web/.trunk-lock"+strings() { for v in $1; do printf '\t\t\t\t<string>%s</string>\n' "$v"; done; }+DOC_EXTS_XML="$(strings "$DOC_EXTS")" IMPORT_UTIS_XML="$(strings "$IMPORT_UTIS")" NAME="$NAME" PORT="$PORT" JS_API="$JS_API" \+ perl -pe 's/\@APP_NAME\@/$ENV{NAME}/g; s/\@PORT\@/$ENV{PORT}/g; s/\@JS_API\@/$ENV{JS_API}/g;+ s/^\s*\@DOC_EXTS\@\n/$ENV{DOC_EXTS_XML}\n/; s/^\@IMPORT_UTIS\@\n/$ENV{IMPORT_UTIS_XML}\n/' \+ "$HERE/Shared/Info.plist.in" >"$SRC/Info.plist"+plutil -lint -s "$SRC/Info.plist"++cat >"$OUT/project.yml" <<YML+name: $NAME+options:+ deploymentTarget:+ iOS: "17.0"+settings:+ base:+ MARKETING_VERSION: "1.0"+ CURRENT_PROJECT_VERSION: "1"+ SWIFT_VERSION: "5.0"+ DEVELOPMENT_TEAM: "${TEAM:-}"+ CODE_SIGN_STYLE: Automatic+targets:+ $NAME:+ type: application+ platform: iOS+ sources:+ - path: $NAME+ # The static site from trunk, kept as a folder so its layout (fonts/…) survives. It sits+ # beside $NAME/, not inside it: some XcodeGen versions drop the parent of a nested folder.+ - path: web+ type: folder+ buildPhase: resources+ settings:+ base:+ PRODUCT_BUNDLE_IDENTIFIER: $BUNDLE_ID+ PRODUCT_NAME: $NAME+ TARGETED_DEVICE_FAMILY: "2"+ INFOPLIST_FILE: $NAME/Info.plist+ GENERATE_INFOPLIST_FILE: NO+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon+YML+(cd "$OUT" && xcodegen generate --quiet)+echo "Generated $OUT/$NAME.xcodeproj ($BUNDLE_ID)"++case "$MODE" in+ --sim)+ DEV="${SIM_DEVICE:-$(xcrun simctl list devices available | grep -E '^[[:space:]]+iPad' | grep -m1 -E 'Pro|Air' | sed -E 's/^ *(.*) \(([-0-9A-F]+)\).*/\2/')}"+ [ -n "$DEV" ] || { echo "error: no iPad simulator (Xcode › Settings › Components)" >&2; exit 1; }+ xcodebuild -project "$OUT/$NAME.xcodeproj" -scheme "$NAME" -configuration Debug \+ -destination "id=$DEV" -derivedDataPath "$OUT/derived" build -quiet+ xcrun simctl boot "$DEV" 2>/dev/null || true+ open "$(xcode-select -p)/Applications/Simulator.app" 2>/dev/null || true+ xcrun simctl install "$DEV" "$OUT/derived/Build/Products/Debug-iphonesimulator/$NAME.app"+ xcrun simctl launch "$DEV" "$BUNDLE_ID"+ ;;+ --device)+ [ -n "${TEAM:-}" ] || { echo "error: set TEAM to your Apple developer team id" >&2; exit 1; }+ DEV="${DEVICE:-$(xcrun devicectl list devices 2>/dev/null | awk '/iPad/ && /available/ {for (i=1;i<=NF;i++) if ($i ~ /^[0-9A-F]{8}-/) {print $i; exit}}')}"+ [ -n "$DEV" ] || { echo "error: no connected iPad (unlock it and trust this Mac)" >&2; exit 1; }+ xcodebuild -project "$OUT/$NAME.xcodeproj" -scheme "$NAME" -configuration Release \+ -destination "platform=iOS,id=$DEV" -derivedDataPath "$OUT/derived" -allowProvisioningUpdates build -quiet+ xcrun devicectl device install app --device "$DEV" "$OUT/derived/Build/Products/Release-iphoneos/$NAME.app"+ xcrun devicectl device process launch --device "$DEV" "$BUNDLE_ID" || \+ echo "Installed. If it won't open: on the iPad, Settings › General › VPN & Device Management › trust your developer certificate."+ ;;+esac