diff --git a/macos/fight-runner/.gitignore b/macos/fight-runner/.gitignore new file mode 100644 index 0000000000..30bcfa4ed5 --- /dev/null +++ b/macos/fight-runner/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/macos/fight-runner/Package.swift b/macos/fight-runner/Package.swift new file mode 100644 index 0000000000..171db262ef --- /dev/null +++ b/macos/fight-runner/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "ACFightRunner", + platforms: [.macOS(.v12)], + targets: [ + .executableTarget( + name: "ac-fight-runner", + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("WebKit"), + .linkedFramework("GameController"), + ] + ) + ] +) diff --git a/macos/fight-runner/README.md b/macos/fight-runner/README.md new file mode 100644 index 0000000000..6223ae3ede --- /dev/null +++ b/macos/fight-runner/README.md @@ -0,0 +1,55 @@ +# AC Fight Runner for macOS + +A deliberately small native macOS host for running the live `fight.mjs` piece +through `WKWebView`. It is a comparison BIOS for the Xbox dynamic shell: both +load the deployed web runtime, while their native controller and WebView paths +remain independently measurable. + +## Run + +```sh +cd macos/fight-runner +swift run ac-fight-runner +``` + +Use a local AC server or another endpoint without rebuilding: + +```sh +swift run ac-fight-runner --url http://localhost:8888/fight +AC_FIGHT_URL=https://staging.example/fight swift run ac-fight-runner +``` + +`Cmd-R` reloads from origin. For automatic reload, point the runner at a tiny +endpoint whose response body or `ETag` changes when a deployment is ready: + +```sh +swift run ac-fight-runner \ + --url http://localhost:8888/fight \ + --reload-endpoint http://localhost:8888/api/build-id +``` + +The host polls native `GameController` state at 60 Hz, logs controller identity +and connection changes, displays identities at bottom-right, and publishes +snapshots to the piece as both `window.acNativeGamepads` and the +`ac-native-gamepads` DOM event. This is additive: WebKit's standard Gamepad API +continues to work when available. + +## Where this fits + +- `ac-electron/` is the mature macOS/Windows desktop BIOS. Its flip view already + bridges Chromium host gamepads into a guest webview and has CDP automation. +- `slab/menuband/Sources/MenuBand/AestheticWebWindow.swift` is the closest + existing native macOS BIOS: it already embeds AC in WKWebView and injects a + native bridge. Its `GamepadManager.swift` establishes GameController identity, + background monitoring, main-queue handlers, and positional face-button + semantics. This runner follows those contracts without importing the whole + piano/menubar executable. +- `slab/menubar-swift/` supplies the broader native AppKit host conventions, but + is host tooling rather than an AC piece container. +- `macos/fight-runner/` is intentionally focused: one native window, one live + piece, native controller telemetry, and no packaging or account machinery. +- `xbox/` is the distribution target. Compare controller-to-piece timestamps and + audio/visual response between its WebView2 shell and this WKWebView shell. + +Executable updates still require rebuilding this host. Piece/runtime updates do +not: change the URL content or reload endpoint and the same binary loads them. diff --git a/macos/fight-runner/Sources/ac-fight-runner/main.swift b/macos/fight-runner/Sources/ac-fight-runner/main.swift new file mode 100644 index 0000000000..73c033fe65 --- /dev/null +++ b/macos/fight-runner/Sources/ac-fight-runner/main.swift @@ -0,0 +1,194 @@ +import AppKit +import GameController +import WebKit + +private struct Options { + var baseURL = ProcessInfo.processInfo.environment["AC_FIGHT_URL"] ?? "https://aesthetic.computer/fight" + var watchURL = ProcessInfo.processInfo.environment["AC_FIGHT_RELOAD_ENDPOINT"] + + init(arguments: [String]) { + var index = 1 + while index < arguments.count { + switch arguments[index] { + case "--url" where index + 1 < arguments.count: + index += 1; baseURL = arguments[index] + case "--reload-endpoint" where index + 1 < arguments.count: + index += 1; watchURL = arguments[index] + case "--help", "-h": + print("usage: ac-fight-runner [--url URL] [--reload-endpoint URL]") + exit(0) + default: break + } + index += 1 + } + } +} + +private final class Runner: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler { + private let options = Options(arguments: CommandLine.arguments) + private var window: NSWindow! + private var webView: WKWebView! + private var controllerTimer: Timer? + private var reloadTimer: Timer? + private var reloadTag: String? + + func applicationDidFinishLaunching(_ notification: Notification) { + let scripts = WKUserContentController() + scripts.add(self, name: "acNative") + scripts.addUserScript(WKUserScript(source: Self.hostScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + let configuration = WKWebViewConfiguration() + configuration.userContentController = scripts + configuration.websiteDataStore = .default() + + webView = WKWebView(frame: .zero, configuration: configuration) + webView.customUserAgent = "AestheticComputerFightRunner/1 (Macintosh; native-host)" + webView.navigationDelegate = self + window = NSWindow( + contentRect: NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 720), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "Aesthetic Computer — Fight (native macOS host)" + window.contentView = webView + window.makeKeyAndOrderFront(nil) + window.toggleFullScreen(nil) + + installMenu() + observeControllers() + load() + if options.watchURL != nil { startReloadWatch() } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + private func load() { + guard let url = URL(string: options.baseURL) else { + fputs("invalid --url: \(options.baseURL)\n", stderr); return + } + print("[fight-runner] loading \(url.absoluteString)") + webView.load(URLRequest(url: url, cachePolicy: .reloadRevalidatingCacheData)) + } + + private func reload() { + print("[fight-runner] reload") + webView.reloadFromOrigin() + } + + private func installMenu() { + let menu = NSMenu() + let appItem = NSMenuItem(); menu.addItem(appItem) + let appMenu = NSMenu(); appItem.submenu = appMenu + appMenu.addItem(withTitle: "Reload Fight", action: #selector(reloadAction), keyEquivalent: "r").target = self + appMenu.addItem(withTitle: "Toggle Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f").target = window + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + NSApp.mainMenu = menu + } + + @objc private func reloadAction() { reload() } + + private func observeControllers() { + if #available(macOS 11.3, *) { GCController.shouldMonitorBackgroundEvents = true } + NotificationCenter.default.addObserver(forName: .GCControllerDidConnect, object: nil, queue: .main) { [weak self] note in + guard let pad = note.object as? GCController else { return } + print("[fight-runner] controller connected: \(pad.vendorName ?? "unknown") / \(pad.productCategory)") + self?.publishControllers() + } + NotificationCenter.default.addObserver(forName: .GCControllerDidDisconnect, object: nil, queue: .main) { [weak self] note in + guard let pad = note.object as? GCController else { return } + print("[fight-runner] controller disconnected: \(pad.vendorName ?? "unknown")") + self?.publishControllers() + } + GCController.startWirelessControllerDiscovery(completionHandler: {}) + for controller in GCController.controllers() { controller.handlerQueue = .main } + controllerTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in self?.publishControllers() } + } + + private func publishControllers() { + let pads: [[String: Any]] = GCController.controllers().enumerated().map { index, controller in + var pad: [String: Any] = [ + "index": index, + "id": controller.vendorName ?? controller.productCategory, + "category": controller.productCategory, + "connected": true, + "timestamp": ProcessInfo.processInfo.systemUptime * 1000, + ] + if let g = controller.extendedGamepad { + pad["axes"] = [g.leftThumbstick.xAxis.value, g.leftThumbstick.yAxis.value, + g.rightThumbstick.xAxis.value, g.rightThumbstick.yAxis.value] + pad["buttons"] = [ + button(g.buttonA, "A"), button(g.buttonB, "B"), button(g.buttonX, "X"), button(g.buttonY, "Y"), + button(g.leftShoulder, "LB"), button(g.rightShoulder, "RB"), + button(g.leftTrigger, "LT"), button(g.rightTrigger, "RT"), + button(g.dpad.up, "UP"), button(g.dpad.down, "DOWN"), + button(g.dpad.left, "LEFT"), button(g.dpad.right, "RIGHT"), + ] + } + return pad + } + guard JSONSerialization.isValidJSONObject(pads), + let data = try? JSONSerialization.data(withJSONObject: pads), + let json = String(data: data, encoding: .utf8) else { return } + webView.evaluateJavaScript("window.__acNativeGamepads && window.__acNativeGamepads(\(json))") + } + + private func button(_ input: GCControllerButtonInput, _ label: String) -> [String: Any] { + ["label": label, "pressed": input.isPressed, "value": input.value] + } + + private func startReloadWatch() { + reloadTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.pollReloadEndpoint() } + pollReloadEndpoint() + } + + private func pollReloadEndpoint() { + guard let text = options.watchURL, let url = URL(string: text) else { return } + var request = URLRequest(url: url); request.cachePolicy = .reloadIgnoringLocalCacheData + URLSession.shared.dataTask(with: request) { [weak self] data, response, _ in + guard let self, let data, + let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return } + let tag = http.value(forHTTPHeaderField: "ETag") ?? String(data: data, encoding: .utf8) + DispatchQueue.main.async { + defer { self.reloadTag = tag } + if let old = self.reloadTag, old != tag { self.reload() } + } + }.resume() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + print("[fight-runner] ready: \(webView.url?.absoluteString ?? "unknown")") + publishControllers() + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + print("[fight-web] \(message.body)") + } + + private static let hostScript = #""" + (() => { + window.acNativeHost = { platform: 'macOS', runner: 'fight', version: 1 }; + window.__acNativeGamepads = (pads) => { + window.acNativeGamepads = pads; + window.dispatchEvent(new CustomEvent('ac-native-gamepads', { detail: pads })); + const out = document.getElementById('ac-native-controller-overlay'); + if (out) out.textContent = pads.length ? pads.map(p => `${p.index + 1}: ${p.id}`).join('\n') : 'NO CONTROLLER'; + }; + addEventListener('DOMContentLoaded', () => { + const out = document.createElement('pre'); + out.id = 'ac-native-controller-overlay'; out.textContent = 'NO CONTROLLER'; + out.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:2147483647;margin:0;padding:7px 9px;background:#0009;color:#fff;font:11px/1.3 monospace;pointer-events:none'; + document.documentElement.appendChild(out); + }); + const original = console.log; + console.log = (...args) => { original(...args); try { webkit.messageHandlers.acNative.postMessage(args.map(String).join(' ')); } catch (_) {} }; + })(); + """# +} + +private let application = NSApplication.shared +private let runner = Runner() +application.delegate = runner +application.setActivationPolicy(.regular) +application.activate(ignoringOtherApps: true) +application.run()