eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev f38aaac1f9146b9302d6c3fa3334e4be233bf46a parent 3f8e1ea
26 files changed, +479 −10
  • apple/.gitignore +3 −0
  • apple/Infinity/AccountWebView.swift +110 −0
  • apple/Infinity/Assets.xcassets/AppIcon.appiconset/Contents.json +14 −0
  • apple/Infinity/Assets.xcassets/AppIcon.appiconset/icon_1024.png binary
  • apple/Infinity/InfinityApp.swift +51 −0
  • apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/Contents.json +68 −0
  • apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png binary
  • apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_128.png binary
  • apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_16.png binary
  • apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_256.png binary
  • apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_32.png binary
  • apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_512.png binary
  • apple/InfinityMac/Assets.xcassets/AppIcon.appiconset/icon_64.png binary
  • apple/InfinityMac/InfinityMac.entitlements +1 −0
  • apple/project.yml +63 −0
  • go.mod +1 −1
  • internal/infinity/client_test.go +38 −0
  • internal/infinity/static/client.js +9 −2
  • internal/infinity/static/dialogs.js +6 −1
  • internal/infinity/static/onboarding.js +8 −1
  • internal/infinityhome/home.go +27 −0
  • internal/infinityhome/home_test.go +11 −1
  • test/infinity/browser.mjs +4 −4
  • test/infinity/live.mjs +20 −0
  • tools/infinity/check +28 −0
  • tools/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()}