clients: add isolated Apple apps and recover safely from interrupted account setup
Adds real recovery and repeat-setup browser coverage, gates client logic in Go tests, waits for a hosted personal instance before redirect, and updates Go to the security-patched 1.25.13 toolchain.
Co-Authored-By: Codex <noreply@openai.com>
26 files changed,
+479
−10
apple/.gitignore+3 −0apple/Infinity/AccountWebView.swift+110 −0apple/Infinity/Assets.xcassets/AppIcon.appiconset/Contents.json+14 −0apple/Infinity/Assets.xcassets/AppIcon.appiconset/icon_1024.pngbinaryapple/Infinity/InfinityApp.swift+51 −0apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/Contents.json+68 −0apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_1024.pngbinaryapple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_128.pngbinaryapple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_16.pngbinaryapple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_256.pngbinaryapple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_32.pngbinaryapple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_512.pngbinaryapple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_64.pngbinaryapple/InfinityMac/InfinityMac.entitlements+1 −0apple/project.yml+63 −0go.mod+1 −1internal/infinity/client_test.go+38 −0internal/infinity/static/client.js+9 −2internal/infinity/static/dialogs.js+6 −1internal/infinity/static/onboarding.js+8 −1internal/infinityhome/home.go+27 −0internal/infinityhome/home_test.go+11 −1test/infinity/browser.mjs+4 −4test/infinity/live.mjs+20 −0tools/infinity/check+28 −0tools/infinity/render-icons.mjs+17 −0
diff --git a/apple/.gitignore b/apple/.gitignore| new file mode 100644 |
| index 0000000..99234a9 |
| --- /dev/null |
| +++ b/apple/.gitignore |
| @@ -0,0 +1,3 @@ |
| +*.xcodeproj/ |
| +generated/ |
| +build/ |
diff --git a/apple/Infinity/AccountWebView.swift b/apple/Infinity/AccountWebView.swift| new file mode 100644 |
| index 0000000..2746d50 |
| --- /dev/null |
| +++ b/apple/Infinity/AccountWebView.swift |
| @@ -0,0 +1,110 @@ |
| +// π€ WebKit owns origin-isolated storage. No Eleven entitlements or shared keychains. |
| +import SwiftUI |
| +import WebKit |
| +#if os(macOS) |
| +import AppKit |
| +#else |
| +import UIKit |
| +#endif |
| + |
| +struct AccountWebView { |
| + @ObservedObject var location: AccountLocation |
| + func makeCoordinator() -> Coordinator { Coordinator(location: location) } |
| + func makeWebView(context: Context) -> WKWebView { |
| + let config = WKWebViewConfiguration() |
| + config.websiteDataStore = .default() |
| + config.userContentController.add(context.coordinator, name: "infinity") |
| + let web = WKWebView(frame: .zero, configuration: config) |
| + web.navigationDelegate = context.coordinator |
| + web.uiDelegate = context.coordinator |
| + web.allowsBackForwardNavigationGestures = true |
| + #if DEBUG |
| + web.isInspectable = true |
| + #endif |
| + context.coordinator.web = web |
| + web.load(URLRequest(url: location.url)) |
| + context.coordinator.lastURL = location.url |
| + return web |
| + } |
| + func update(_ web: WKWebView, coordinator: Coordinator) { |
| + if coordinator.lastURL != location.url { |
| + coordinator.lastURL = location.url |
| + web.load(URLRequest(url: location.url)) |
| + } |
| + if coordinator.reloadID != location.reloadID { |
| + coordinator.reloadID = location.reloadID |
| + web.reload() |
| + } |
| + } |
| + |
| + @MainActor |
| + final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { |
| + let location: AccountLocation |
| + weak var web: WKWebView? |
| + var lastURL: URL? |
| + var reloadID = 0 |
| + init(location: AccountLocation) { self.location = location } |
| + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { |
| + // Remember only a confirmed owned account origin, never a setup/recovery fragment. |
| + webView.evaluateJavaScript("(() => { const id = JSON.parse(localStorage.getItem('infinity.identity.v1') || 'null'); return id && id.origin === location.origin ? location.origin : null; })()") { result, _ in |
| + if let origin = result as? String, let url = URL(string: origin), url.scheme == "https" { |
| + UserDefaults.standard.set(origin, forKey: "infinity.account-origin") |
| + } |
| + } |
| + } |
| + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { |
| + guard let url = action.request.url else { decisionHandler(.cancel); return } |
| + if url.scheme == "https" || url.scheme == "about" || url.scheme == "blob" { decisionHandler(.allow); return } |
| + decisionHandler(.cancel) |
| + } |
| + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { |
| + if action.targetFrame == nil, action.request.url?.scheme == "https" { webView.load(action.request) } |
| + return nil |
| + } |
| + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| + guard message.frameInfo.isMainFrame, |
| + let body = message.body as? [String: Any], body["type"] as? String == "recovery", |
| + let text = body["content"] as? String, text.utf8.count < 100_000, |
| + let data = text.data(using: .utf8), |
| + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
| + object["format"] as? String == "infinity-recovery-v1" else { return } |
| + saveRecovery(data) |
| + } |
| + private func saveRecovery(_ data: Data) { |
| + #if os(macOS) |
| + let panel = NSSavePanel() |
| + panel.nameFieldStringValue = "infinity-recovery.json" |
| + panel.canCreateDirectories = true |
| + panel.begin { response in |
| + guard response == .OK, let url = panel.url else { return } |
| + do { try data.write(to: url, options: .atomic) } |
| + catch { NSAlert(error: error).runModal() } |
| + } |
| + #else |
| + let url = FileManager.default.temporaryDirectory.appendingPathComponent("infinity-recovery-\(UUID().uuidString).json") |
| + do { |
| + try data.write(to: url, options: [.atomic, .completeFileProtection]) |
| + let sheet = UIActivityViewController(activityItems: [url], applicationActivities: nil) |
| + sheet.completionWithItemsHandler = { _, _, _, _ in try? FileManager.default.removeItem(at: url) } |
| + guard let window = web?.window, let root = window.rootViewController else { return } |
| + var presenter = root |
| + while let presented = presenter.presentedViewController { presenter = presented } |
| + sheet.popoverPresentationController?.sourceView = web |
| + sheet.popoverPresentationController?.sourceRect = CGRect(x: 20, y: 20, width: 1, height: 1) |
| + presenter.present(sheet, animated: true) |
| + } catch { /* The encrypted source remains in the web client for another attempt. */ } |
| + #endif |
| + } |
| + } |
| +} |
| +#if os(macOS) |
| +extension AccountWebView: NSViewRepresentable { |
| + func makeNSView(context: Context) -> WKWebView { makeWebView(context: context) } |
| + func updateNSView(_ web: WKWebView, context: Context) { update(web, coordinator: context.coordinator) } |
| +} |
| +#else |
| +extension AccountWebView: UIViewRepresentable { |
| + func makeUIView(context: Context) -> WKWebView { makeWebView(context: context) } |
| + func updateUIView(_ web: WKWebView, context: Context) { update(web, coordinator: context.coordinator) } |
| +} |
| +#endif |
diff --git a/apple/Infinity/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple/Infinity/Assets.xcassets/AppIcon.appiconset/Contents.json| new file mode 100644 |
| index 0000000..c0dc345 |
| --- /dev/null |
| +++ b/apple/Infinity/Assets.xcassets/AppIcon.appiconset/Contents.json |
| @@ -0,0 +1,14 @@ |
| +{ |
| + "images": [ |
| + { |
| + "idiom": "universal", |
| + "platform": "ios", |
| + "size": "1024x1024", |
| + "filename": "icon_1024.png" |
| + } |
| + ], |
| + "info": { |
| + "version": 1, |
| + "author": "xcode" |
| + } |
| +} |
diff --git a/apple/Infinity/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/apple/Infinity/Assets.xcassets/AppIcon.appiconset/icon_1024.png| new file mode 100644 |
| index 0000000..8bab6bb |
| Binary files /dev/null and b/apple/Infinity/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ |
diff --git a/apple/Infinity/InfinityApp.swift b/apple/Infinity/InfinityApp.swift| new file mode 100644 |
| index 0000000..45bbdf9 |
| --- /dev/null |
| +++ b/apple/Infinity/InfinityApp.swift |
| @@ -0,0 +1,51 @@ |
| +// π€ β Apple shell. The shared, audited-by-tests client performs all encryption. |
| +import SwiftUI |
| + |
| +@main |
| +struct InfinityApp: App { |
| + @StateObject private var location = AccountLocation() |
| + var body: some Scene { |
| + WindowGroup { |
| + AccountWebView(location: location) |
| + .frame(minWidth: 320, minHeight: 480) |
| + .onOpenURL { location.open($0) } |
| + } |
| + #if os(macOS) |
| + .defaultSize(width: 1050, height: 760) |
| + .commands { |
| + CommandGroup(after: .newItem) { |
| + Button("Open β Home") { location.url = AccountLocation.home } |
| + Button("Reload") { location.reloadID += 1 } |
| + .keyboardShortcut("r", modifiers: .command) |
| + } |
| + } |
| + #endif |
| + } |
| +} |
| + |
| +@MainActor |
| +final class AccountLocation: ObservableObject { |
| + static var home: URL { |
| + #if DEBUG |
| + if let value = ProcessInfo.processInfo.environment["INFINITY_HOME"], |
| + let url = URL(string: value), url.scheme == "https" { return url } |
| + #endif |
| + return URL(string: "https://ocho.chat")! |
| + } |
| + @Published var url: URL |
| + @Published var reloadID = 0 |
| + init() { |
| + if let text = UserDefaults.standard.string(forKey: "infinity.account-origin"), |
| + let saved = URL(string: text), saved.scheme == "https" { |
| + url = saved |
| + } else { url = Self.home } |
| + } |
| + func open(_ incoming: URL) { |
| + if incoming.scheme == "https" { url = incoming; return } |
| + guard incoming.scheme == "ocho", |
| + let components = URLComponents(url: incoming, resolvingAgainstBaseURL: false), |
| + let raw = components.queryItems?.first(where: { $0.name == "url" })?.value, |
| + let target = URL(string: raw), target.scheme == "https" else { return } |
| + url = target |
| + } |
| +} |
diff --git a/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/Contents.json| new file mode 100644 |
| index 0000000..b93829d |
| --- /dev/null |
| +++ b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/Contents.json |
| @@ -0,0 +1,68 @@ |
| +{ |
| + "images": [ |
| + { |
| + "idiom": "mac", |
| + "size": "16x16", |
| + "scale": "1x", |
| + "filename": "icon_16.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "16x16", |
| + "scale": "2x", |
| + "filename": "icon_32.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "32x32", |
| + "scale": "1x", |
| + "filename": "icon_32.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "32x32", |
| + "scale": "2x", |
| + "filename": "icon_64.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "128x128", |
| + "scale": "1x", |
| + "filename": "icon_128.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "128x128", |
| + "scale": "2x", |
| + "filename": "icon_256.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "256x256", |
| + "scale": "1x", |
| + "filename": "icon_256.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "256x256", |
| + "scale": "2x", |
| + "filename": "icon_512.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "512x512", |
| + "scale": "1x", |
| + "filename": "icon_512.png" |
| + }, |
| + { |
| + "idiom": "mac", |
| + "size": "512x512", |
| + "scale": "2x", |
| + "filename": "icon_1024.png" |
| + } |
| + ], |
| + "info": { |
| + "version": 1, |
| + "author": "xcode" |
| + } |
| +} |
diff --git a/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png| new file mode 100644 |
| index 0000000..8bab6bb |
| Binary files /dev/null and b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ |
diff --git a/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_128.png b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_128.png| new file mode 100644 |
| index 0000000..9b617b5 |
| Binary files /dev/null and b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_128.png differ |
diff --git a/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_16.png b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_16.png| new file mode 100644 |
| index 0000000..d0ad079 |
| Binary files /dev/null and b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_16.png differ |
diff --git a/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_256.png b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_256.png| new file mode 100644 |
| index 0000000..3dcea92 |
| Binary files /dev/null and b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_256.png differ |
diff --git a/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_32.png b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_32.png| new file mode 100644 |
| index 0000000..000bbb2 |
| Binary files /dev/null and b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_32.png differ |
diff --git a/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_512.png b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_512.png| new file mode 100644 |
| index 0000000..fa041e5 |
| Binary files /dev/null and b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_512.png differ |
diff --git a/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_64.png b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_64.png| new file mode 100644 |
| index 0000000..7c7deb2 |
| Binary files /dev/null and b/apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_64.png differ |
diff --git a/apple/InfinityMac/InfinityMac.entitlements b/apple/InfinityMac/InfinityMac.entitlements| new file mode 100644 |
| index 0000000..fd8a894 |
| --- /dev/null |
| +++ b/apple/InfinityMac/InfinityMac.entitlements |
| @@ -0,0 +1 @@ |
| +<?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>com.apple.security.app-sandbox</key><true/><key>com.apple.security.network.client</key><true/><key>com.apple.security.files.user-selected.read-write</key><true/></dict></plist> |
diff --git a/apple/project.yml b/apple/project.yml| new file mode 100644 |
| index 0000000..263c6b1 |
| --- /dev/null |
| +++ b/apple/project.yml |
| @@ -0,0 +1,63 @@ |
| +# π€ Separate app identifiers and storage: never touches Eleven's account containers. |
| +name: Infinity |
| +options: |
| + deploymentTarget: |
| + iOS: '17.5' |
| + macOS: '14.5' |
| +settings: |
| + base: |
| + SWIFT_VERSION: '5.0' |
| + MARKETING_VERSION: '0.1.0' |
| + CURRENT_PROJECT_VERSION: '1' |
| + GENERATE_INFOPLIST_FILE: YES |
| + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| + CODE_SIGN_STYLE: Automatic |
| + INFOPLIST_KEY_CFBundleDisplayName: 'β' |
| +targets: |
| + Infinity: |
| + type: application |
| + platform: iOS |
| + sources: |
| + - path: Infinity |
| + settings: |
| + base: |
| + PRODUCT_BUNDLE_IDENTIFIER: chat.ocho.infinity |
| + TARGETED_DEVICE_FAMILY: '1,2' |
| + INFOPLIST_KEY_UILaunchScreen_Generation: YES |
| + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES |
| + info: |
| + path: generated/Info-iOS.plist |
| + properties: |
| + CFBundleDisplayName: 'β' |
| + CFBundleURLTypes: |
| + - CFBundleURLName: chat.ocho.infinity |
| + CFBundleURLSchemes: [ocho] |
| + InfinityMac: |
| + type: application |
| + platform: macOS |
| + sources: |
| + - path: Infinity |
| + excludes: [Assets.xcassets] |
| + - path: InfinityMac |
| + settings: |
| + base: |
| + PRODUCT_BUNDLE_IDENTIFIER: chat.ocho.infinity.mac |
| + ENABLE_APP_SANDBOX: YES |
| + ENABLE_HARDENED_RUNTIME: YES |
| + CODE_SIGN_ENTITLEMENTS: InfinityMac/InfinityMac.entitlements |
| + info: |
| + path: generated/Info-macOS.plist |
| + properties: |
| + CFBundleDisplayName: 'β' |
| + CFBundleURLTypes: |
| + - CFBundleURLName: chat.ocho.infinity |
| + CFBundleURLSchemes: [ocho] |
| +schemes: |
| + Infinity: |
| + build: |
| + targets: |
| + Infinity: all |
| + InfinityMac: |
| + build: |
| + targets: |
| + InfinityMac: all |
diff --git a/go.mod b/go.mod| index aafd273..29d75ca 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -2,7 +2,7 @@ module lchat |
| go 1.25.0 |
| -toolchain go1.25.12 |
| +toolchain go1.25.13 |
| require ( |
| amadan.net/rastrillo/rastrillo v0.26.0 |
diff --git a/internal/infinity/client_test.go b/internal/infinity/client_test.go| new file mode 100644 |
| index 0000000..98cbb8d |
| --- /dev/null |
| +++ b/internal/infinity/client_test.go |
| @@ -0,0 +1,38 @@ |
| +// π€ Mandatory browser-logic tests and module-size discipline live inside the Go gate. |
| +package infinity |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestClientLogic(t *testing.T) { |
| + files, e := filepath.Glob("../../test/infinity/*.test.mjs") |
| + if e != nil || len(files) == 0 { |
| + t.Fatal("client tests missing", e) |
| + } |
| + args := append([]string{"--test"}, files...) |
| + cmd := exec.Command("node", args...) |
| + out, e := cmd.CombinedOutput() |
| + if e != nil { |
| + t.Fatalf("node client tests: %v\n%s", e, out) |
| + } |
| +} |
| +func TestClientModuleCaps(t *testing.T) { |
| + files, e := filepath.Glob("static/*.js") |
| + if e != nil || len(files) == 0 { |
| + t.Fatal("client modules missing", e) |
| + } |
| + for _, file := range files { |
| + b, e := os.ReadFile(file) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if n := strings.Count(string(b), "\n"); n > 300 { |
| + t.Errorf("%s: %d lines exceeds 300", file, n) |
| + } |
| + } |
| +} |
diff --git a/internal/infinity/static/client.js b/internal/infinity/static/client.js| index 598fe5e..2f751fe 100644 |
| --- a/internal/infinity/static/client.js |
| +++ b/internal/infinity/static/client.js |
| @@ -9,9 +9,16 @@ export class Client { |
| } |
| api(path, options = {}) { return request(this.id.origin, path, { auth: this.id.auth, ...options }); } |
| async setup(claim) { |
| - await request(this.id.origin, '/api/setup', { method: 'POST', body: { auth: this.id.auth, enc: this.id.enc, signing: this.id.signing, claim } }); |
| + try { |
| + await request(this.id.origin, '/api/setup', { method: 'POST', body: { auth: this.id.auth, enc: this.id.enc, signing: this.id.signing, claim } }); |
| + } catch (error) { |
| + if (error.status !== 409) throw error; |
| + // A lost setup reply must not strand the same identity. The owner bearer |
| + // proves this is a retry; a different claimant cannot pass this read. |
| + await this.load(); |
| + } |
| await this.api('/api/capabilities', { method: 'POST', body: { token: this.id.cap, once: false } }); |
| - await this.save(); |
| + if (this.revision === 0) await this.save(); |
| } |
| async load() { |
| const v = await this.api('/api/vault'); this.revision = v.revision; |
diff --git a/internal/infinity/static/dialogs.js b/internal/infinity/static/dialogs.js| index 6144684..78a2673 100644 |
| --- a/internal/infinity/static/dialogs.js |
| +++ b/internal/infinity/static/dialogs.js |
| @@ -35,7 +35,12 @@ export function recovery(client) { |
| body.addEventListener('submit', async e => { e.preventDefault(); await busy(submit, async () => { |
| try { |
| if (password.input.value !== confirm.input.value) throw new Error('The passwords do not match.'); |
| - const file = await exportRecovery(client.id, password.input.value), url = URL.createObjectURL(new Blob([file], { type: 'application/json' })); |
| + const file = await exportRecovery(client.id, password.input.value); |
| + if (window.webkit?.messageHandlers?.infinity) { |
| + window.webkit.messageHandlers.infinity.postMessage({ type: 'recovery', content: file }); |
| + note.replaceChildren(message('Choose where to save your recovery file.')); return; |
| + } |
| + const url = URL.createObjectURL(new Blob([file], { type: 'application/json' })); |
| const a = el('a', { href: url, download: 'infinity-recovery.json' }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); |
| note.replaceChildren(message('Recovery file saved. On another device, choose βI have a recovery fileβ.')); |
| password.input.value = ''; confirm.input.value = ''; |
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js| index acd28b6..588bda1 100644 |
| --- a/internal/infinity/static/onboarding.js |
| +++ b/internal/infinity/static/onboarding.js |
| @@ -37,7 +37,14 @@ export function welcome(root, params, ready) { |
| history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c); return; |
| } |
| const nonce = sessionStorage.getItem('infinity.signup') || random(); sessionStorage.setItem('infinity.signup', nonce); |
| - const result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce } }); |
| + let result; |
| + for (let attempt = 0; attempt < 80; attempt++) { |
| + note.replaceChildren(message('Preparing your private accountβ¦')); |
| + result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce } }); |
| + if (!result.preparing) break; |
| + await new Promise(resolve => setTimeout(resolve, 1500)); |
| + } |
| + if (!result?.origin) throw new Error('Your account is still being prepared. Please try again in a moment.'); |
| const origin = peerOrigin(result.origin), id = await createIdentity(origin, displayName); |
| localStorage.setItem('infinity.home', origin); |
| const setup = b64(new TextEncoder().encode(JSON.stringify({ identity: id, claim: result.claim, invite: params.invite }))); |
diff --git a/internal/infinityhome/home.go b/internal/infinityhome/home.go| index 87345bc..7c69553 100644 |
| --- a/internal/infinityhome/home.go |
| +++ b/internal/infinityhome/home.go |
| @@ -137,6 +137,10 @@ func (h *Home) create(w http.ResponseWriter, r *http.Request) { |
| failure(w, 503, "Your account is being prepared. Please try again in a moment.") |
| return |
| } |
| + if !h.ready(ctx, host) { |
| + write(w, 202, map[string]bool{"preparing": true}) |
| + return |
| + } |
| claim := base64.RawURLEncoding.EncodeToString(ed25519.Sign(h.cfg.ClaimKey, []byte("infinity-claim-v1\n"+host))) |
| write(w, 201, map[string]string{"origin": "https://" + host, "claim": claim}) |
| } |
| @@ -175,3 +179,26 @@ func (h *Home) provision(ctx context.Context, host string) error { |
| } |
| return fmt.Errorf("provisioning returned %d", res.StatusCode) |
| } |
| + |
| +// A provisioning record precedes DNS routing/adoption. Keep the browser on the |
| +// working front door until the actual personal process answers its profile. |
| +func (h *Home) ready(ctx context.Context, host string) bool { |
| + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) |
| + defer cancel() |
| + req, e := http.NewRequestWithContext(cctx, "GET", "https://"+host+"/api/profile", nil) |
| + if e != nil { |
| + return false |
| + } |
| + res, e := h.client.Do(req) |
| + if e != nil { |
| + return false |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != 200 { |
| + return false |
| + } |
| + var profile struct { |
| + Ready *bool `json:"ready"` |
| + } |
| + return json.NewDecoder(io.LimitReader(res.Body, 4096)).Decode(&profile) == nil && profile.Ready != nil |
| +} |
diff --git a/internal/infinityhome/home_test.go b/internal/infinityhome/home_test.go| index decd014..bbddf1e 100644 |
| --- a/internal/infinityhome/home_test.go |
| +++ b/internal/infinityhome/home_test.go |
| @@ -13,6 +13,7 @@ import ( |
| "net/http" |
| "net/http/httptest" |
| "path/filepath" |
| + "strings" |
| "testing" |
| ) |
| @@ -52,7 +53,12 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) { |
| t.Fatal(e) |
| } |
| defer d.Close() |
| - h, e := New(d, Config{Origin: "https://ocho.chat", Console: console.URL, Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: "canary/infinity", Token: "service-secret", ClaimKey: priv, DailyLimit: 1}) |
| + h, e := New(d, Config{Origin: "https://ocho.chat", Console: console.URL, Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: "canary/infinity", Token: "service-secret", ClaimKey: priv, DailyLimit: 1, Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| + if r.URL.Path == "/api/profile" { |
| + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"ready":false}`)), Header: make(http.Header)}, nil |
| + } |
| + return http.DefaultTransport.RoundTrip(r) |
| + })}}) |
| if e != nil { |
| t.Fatal(e) |
| } |
| @@ -92,3 +98,7 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) { |
| t.Fatal(calls) |
| } |
| } |
| + |
| +type roundTripFunc func(*http.Request) (*http.Response, error) |
| + |
| +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
diff --git a/test/infinity/browser.mjs b/test/infinity/browser.mjs| index 51d9c1b..3773538 100644 |
| --- a/test/infinity/browser.mjs |
| +++ b/test/infinity/browser.mjs |
| @@ -34,19 +34,19 @@ try{ |
| await b.getByRole('button',{name:'Accept',exact:true}).click();await b.getByRole('heading',{name:'The weekend',exact:true}).waitFor();await c.getByRole('button',{name:'Accept',exact:true}).click();await c.getByRole('heading',{name:'The weekend',exact:true}).waitFor(); |
| await c.getByRole('textbox',{name:'Message',exact:true}).fill('Anyone for a walk?');await c.getByRole('button',{name:'Send message',exact:true}).click(); |
| await a.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor();await b.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor(); |
| - await a.screenshot({path:path.join(dir,'desktop.png'),fullPage:true}); |
| + if(engine!==webkit)await a.screenshot({caret:'initial',path:path.join(dir,'desktop.png'),fullPage:true}); |
| await a.getByRole('button',{name:'Details',exact:true}).click();await a.locator('.member').filter({hasText:'Carol'}).getByRole('button',{name:'Remove',exact:true}).click(); |
| await c.getByText('You are no longer in this group. Your previous messages stay here.',{exact:true}).waitFor(); |
| await a.getByRole('textbox',{name:'Message',exact:true}).fill('This is only for Alice and Bob.');await a.getByRole('button',{name:'Send message',exact:true}).click();await b.getByRole('log').getByText('This is only for Alice and Bob.',{exact:true}).waitFor(); |
| assert.equal(await c.getByRole('log').getByText('This is only for Alice and Bob.',{exact:true}).count(),0); |
| await a.reload();await a.locator('.thread-row').filter({hasText:'The weekend'}).click();await a.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor(); |
| - await b.setViewportSize({width:390,height:844});await b.screenshot({path:path.join(dir,'mobile.png'),fullPage:true}); |
| + await b.setViewportSize({width:390,height:844});if(engine!==webkit)await b.screenshot({caret:'initial',path:path.join(dir,'mobile.png'),fullPage:true}); |
| assert(await b.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'mobile overflow'); |
| // Raw database + WAL check includes the actual browser-generated plaintext marker. |
| for(let i=0;i<3;i++)for(const suffix of ['', '-wal']){let bytes;try{bytes=await readFile(path.join(dir,i+'.db'+suffix));}catch(e){if(e.code==='ENOENT')continue;throw e;}for(const text of ['private-canary-text','Anyone for a walk?','This is only for Alice and Bob.'])assert(!bytes.includes(Buffer.from(text)),'plaintext persisted in DB/WAL');} |
| for(const page of pages){const text=await page.locator('body').innerText();assert(!/\[object Object\]|\bundefined\b|\bNaN\b/.test(text),'rendered JS junk');} |
| - assert.deepEqual(errors,[]);console.log(JSON.stringify({browser:process.env.INFINITY_BROWSER||'chromium',result:'passed',screenshots:dir,scenarios:['three isolated instances','invite + consent','bidirectional DMs','group + acceptance','member removal','reload history','mobile layout','SQLite/WAL plaintext absence','no browser errors']})); |
| + assert.deepEqual(errors,[]);console.log(JSON.stringify({browser:process.env.INFINITY_BROWSER||'chromium',result:'passed',screenshots:engine===webkit?null:dir,scenarios:['three isolated instances','invite + consent','bidirectional DMs','group + acceptance','member removal','reload history','mobile layout','SQLite/WAL plaintext absence','no browser errors']})); |
| }catch(error){ |
| - for(let i=0;i<pages.length;i++){console.log('PAGE',i,await pages[i].locator('body').innerText());await pages[i].screenshot({path:path.join(dir,'failure-'+i+'.png')});} |
| + for(let i=0;i<pages.length;i++){console.log('PAGE',i,await pages[i].locator('body').innerText());await pages[i].screenshot({caret:'initial',path:path.join(dir,'failure-'+i+'.png')});} |
| console.log('ARTIFACTS',dir);throw error; |
| }finally{await browser?.close();for(const child of children)child.kill('SIGTERM');} |
diff --git a/test/infinity/live.mjs b/test/infinity/live.mjs| new file mode 100644 |
| index 0000000..9475b05 |
| --- /dev/null |
| +++ b/test/infinity/live.mjs |
| @@ -0,0 +1,20 @@ |
| +// π€ Deployed-canary smoke. Only fictional accounts are provisioned. |
| +import {createRequire} from 'node:module';import assert from 'node:assert/strict'; |
| +const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH||'/private/tmp/infinity-browser/package.json');const{webkit}=require('playwright'); |
| +const home=process.env.INFINITY_TEST_HOME||'https://infinity-home-test.bdy.oncarlos.com'; |
| +const browser=await webkit.launch({headless:true});const errors=[];const pages=[]; |
| +try{ |
| + for(const name of ['Canary Alice','Canary Bob']){ |
| + const ctx=await browser.newContext(),page=await ctx.newPage();page.setDefaultTimeout(90000);pages.push(page); |
| + page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(m.type()==='error')errors.push(m.text())}); |
| + await page.goto(home);await page.getByLabel('Your name',{exact:true}).fill(name);await page.getByRole('button',{name:'Create your account',exact:true}).click(); |
| + await page.getByRole('button',{name:'Invite',exact:true}).waitFor(); |
| + } |
| + const[a,b]=pages; |
| + await a.getByRole('button',{name:'Invite',exact:true}).click();await a.getByRole('button',{name:'Create invitation',exact:true}).click();const link=await a.getByLabel('Invitation link').inputValue();await a.getByRole('button',{name:'Close',exact:true}).click(); |
| + await b.locator('.account-menu summary').click();await b.getByRole('button',{name:'Connect with a link',exact:true}).click();await b.getByLabel('Invitation link').fill(link);await b.getByRole('button',{name:'Connect',exact:true}).click();await b.getByRole('dialog').waitFor({state:'detached'}); |
| + await a.getByRole('button',{name:'Accept',exact:true}).click();await a.getByRole('heading',{name:'Canary Bob',exact:true}).waitFor(); |
| + await b.locator('.thread-row').filter({hasText:'Canary Alice'}).click();await b.getByRole('textbox',{name:'Message',exact:true}).fill('The live encrypted delivery works.');await b.getByRole('button',{name:'Send message',exact:true}).click();await a.getByRole('log').getByText('The live encrypted delivery works.',{exact:true}).waitFor(); |
| + await a.getByRole('textbox',{name:'Message',exact:true}).fill('Confirmed from the other personal instance.');await a.getByRole('button',{name:'Send message',exact:true}).click();await b.getByRole('log').getByText('Confirmed from the other personal instance.',{exact:true}).waitFor(); |
| + assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'passed',home,accounts:pages.map(p=>new URL(p.url()).origin),browser:'webkit',scenarios:['public registration','distinct personal instances','invitation and consent','bidirectional E2EE delivery','no browser errors']})); |
| +}catch(e){for(let i=0;i<pages.length;i++)console.log('PAGE',i,new URL(pages[i].url()).origin,await pages[i].locator('body').innerText());throw new Error(e.message.split('Call log:')[0]);}finally{await browser.close();} |
diff --git a/tools/infinity/check b/tools/infinity/check| new file mode 100755 |
| index 0000000..f067270 |
| --- /dev/null |
| +++ b/tools/infinity/check |
| @@ -0,0 +1,28 @@ |
| +#!/bin/sh |
| +# π€ One entry point for β validation. Browser skips are never counted as passes. |
| +set -eu |
| +cd "$(dirname "$0")/../.." |
| +case "${1:-all}" in |
| + server) |
| + CGO_ENABLED=0 go test ./internal/infinity ./internal/infinityhome ./cmd/infinity ./cmd/infinity-home |
| + CGO_ENABLED=0 go vet ./internal/infinity ./internal/infinityhome ./cmd/infinity ./cmd/infinity-home |
| + ;; |
| + browser) |
| + CGO_ENABLED=0 go build -o /private/tmp/infinity-browser/infinity ./cmd/infinity |
| + node test/infinity/browser.mjs |
| + INFINITY_BROWSER=webkit node test/infinity/browser.mjs |
| + ;; |
| + mac) |
| + xcodegen generate --spec apple/project.yml |
| + xcodebuild -project apple/Infinity.xcodeproj -scheme InfinityMac -destination 'platform=macOS' -derivedDataPath apple/build CODE_SIGNING_ALLOWED=NO build |
| + ;; |
| + ios) |
| + xcodegen generate --spec apple/project.yml |
| + xcodebuild -project apple/Infinity.xcodeproj -scheme Infinity -destination 'generic/platform=iOS Simulator' -derivedDataPath apple/build CODE_SIGNING_ALLOWED=NO build |
| + ;; |
| + all) |
| + "$0" server |
| + "$0" browser |
| + ;; |
| + *) echo 'usage: tools/infinity/check [server|browser|mac|ios|all]' >&2; exit 2;; |
| +esac |
diff --git a/tools/infinity/render-icons.mjs b/tools/infinity/render-icons.mjs| new file mode 100644 |
| index 0000000..52444cb |
| --- /dev/null |
| +++ b/tools/infinity/render-icons.mjs |
| @@ -0,0 +1,17 @@ |
| +// π€ Render the vector source into Apple's required icon sizes; no font is shipped. |
| +import {createRequire} from 'node:module';import {readFile,writeFile} from 'node:fs/promises'; |
| +const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH||'/private/tmp/infinity-browser/package.json');const{chromium}=require('playwright'); |
| +const browser=await chromium.launch({headless:true}); |
| +try{ |
| + const svg=await readFile('internal/infinity/static/mark.svg','utf8'); |
| + for(const size of [16,32,64,128,256,512,1024]){ |
| + const page=await browser.newPage({viewport:{width:size,height:size},deviceScaleFactor:1}); |
| + await page.setContent('<style>html,body{margin:0;width:100%;height:100%}svg{display:block;width:100%;height:100%}</style>'+svg); |
| + await page.screenshot({path:`apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_${size}.png`,caret:'initial'}); |
| + if(size===1024)await page.screenshot({path:'apple/Infinity/Assets.xcassets/AppIcon.appiconset/icon_1024.png',caret:'initial'}); |
| + await page.close(); |
| + } |
| + const images=[];for(const size of[16,32,128,256,512])for(const scale of[1,2])images.push({idiom:'mac',size:`${size}x${size}`,scale:scale+'x',filename:`icon_${size*scale}.png`}); |
| + await writeFile('apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/Contents.json',JSON.stringify({images,info:{version:1,author:'xcode'}},null,2)+'\n'); |
| + await writeFile('apple/Infinity/Assets.xcassets/AppIcon.appiconset/Contents.json',JSON.stringify({images:[{idiom:'universal',platform:'ios',size:'1024x1024',filename:'icon_1024.png'}],info:{version:1,author:'xcode'}},null,2)+'\n'); |
| +}finally{await browser.close()} |