From c9ae64a0b4034d5b545f8abac9656799e26d9fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Pu=C5=A1k=C3=A1=C4=8D?= Date: Tue, 13 Oct 2026 14:15:12 +0200 Subject: [PATCH] Restore what PR #21 reverted by accident; build 5 23b0473 ("Shot 2: no possessive Figma") was made on a tree from before PRs #18-#20, so besides the screenshot subtitle it silently undid: - the passkey/WebAuthn handling from PR #20 (polyfill, toast, sign-in tips, README, polyfill tests) and build number 5; - the main-actor and Sendable warning fixes from PR #19; - TARGETED_DEVICE_FAMILY "2" on the test and prototype targets (PR #18). This reverts that commit except store/shots.json, whose new subtitle was the intended change. The tree now matches a419af5 apart from that file and the private/ line in .gitignore. Co-Authored-By: Claude Opus 5.5 --- .../PointerLocker/BrowserViewController.swift | 18 ++++- Apps/PointerLocker/MouseBridge.swift | 5 +- .../Onboarding/ReadinessMonitor.swift | 6 +- Apps/PointerLocker/PopupViewController.swift | 3 + .../Resources/pointerlock-polyfill.js | 32 ++++++++ .../PointerLocker/Services/FigmaProfile.swift | 2 +- .../Services/GeForceNowProfile.swift | 2 +- README.md | 10 +++ project.yml | 4 +- .../ReadinessMonitorTests.swift | 8 +- .../RootViewControllerTests.swift | 13 ++-- tests/polyfill.test.mjs | 74 +++++++++++++++++++ 12 files changed, 159 insertions(+), 18 deletions(-) diff --git a/Apps/PointerLocker/BrowserViewController.swift b/Apps/PointerLocker/BrowserViewController.swift index 0509db2..9a1d3f0 100644 --- a/Apps/PointerLocker/BrowserViewController.swift +++ b/Apps/PointerLocker/BrowserViewController.swift @@ -249,10 +249,10 @@ final class BrowserViewController: UIViewController { self?.forceUnlock() }) observers.append(center.addObserver(forName: UIScene.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in - self?.sessionKeeper.didEnterBackground() + MainActor.assumeIsolated { self?.sessionKeeper.didEnterBackground() } }) observers.append(center.addObserver(forName: UIScene.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in - self?.sessionKeeper.willEnterForeground() + MainActor.assumeIsolated { self?.sessionKeeper.willEnterForeground() } }) } @@ -348,6 +348,12 @@ final class BrowserViewController: UIViewController { // MARK: - Page messages extension BrowserViewController: WKScriptMessageHandler { + /// Shown when a page asks for a passkey or security key (the polyfill + /// hides WebAuthn, which WKWebView can't run without Apple's browser + /// entitlement). + static let passkeyNotice = "Passkeys and security keys don't work in Mouselook. " + + "Choose another way to sign in, such as a code from your authenticator app or a prompt on your phone." + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard message.frameInfo.isMainFrame, let body = message.body as? [String: Any], @@ -361,6 +367,14 @@ extension BrowserViewController: WKScriptMessageHandler { case "health": health.receive(check: body["check"] as? String ?? "", result: body["result"] as? String ?? "", code: body["code"] as? String ?? "", host: webView.url?.host ?? "", locked: pageWantsLock) + case "webauthn": + // Sign-in often runs in a popup sheet, which would hide our toast. + if message.webView !== webView, + let popup = (presentedViewController as? UINavigationController)?.viewControllers.first as? PopupViewController { + popup.toast.show(Self.passkeyNotice) + } else { + toast.show(Self.passkeyNotice) + } default: break } } diff --git a/Apps/PointerLocker/MouseBridge.swift b/Apps/PointerLocker/MouseBridge.swift index d89f2a8..b33db37 100644 --- a/Apps/PointerLocker/MouseBridge.swift +++ b/Apps/PointerLocker/MouseBridge.swift @@ -53,7 +53,10 @@ final class MouseBridge { private let hub: MouseEventHub private var hubToken: MouseEventHub.Token? - init(usesDisplayLink: Bool = true, hub: MouseEventHub = .shared) { + /// `hub` defaults to `.shared`, resolved here rather than as a default + /// argument, which Swift evaluates off the main actor. + init(usesDisplayLink: Bool = true, hub: MouseEventHub? = nil) { + let hub = hub ?? .shared self.hub = hub hubToken = hub.addListener { [weak self] event in guard let self else { return } diff --git a/Apps/PointerLocker/Onboarding/ReadinessMonitor.swift b/Apps/PointerLocker/Onboarding/ReadinessMonitor.swift index 566177a..626e6a6 100644 --- a/Apps/PointerLocker/Onboarding/ReadinessMonitor.swift +++ b/Apps/PointerLocker/Onboarding/ReadinessMonitor.swift @@ -27,8 +27,10 @@ final class ReadinessMonitor { @ObservationIgnored private var observers: [NSObjectProtocol] = [] @ObservationIgnored private var timer: Timer? - init(hub: MouseEventHub = .shared, clock: @escaping () -> TimeInterval = CACurrentMediaTime) { - self.hub = hub + /// `hub` defaults to `.shared`, resolved here rather than as a default + /// argument, which Swift evaluates off the main actor. + init(hub: MouseEventHub? = nil, clock: @escaping () -> TimeInterval = CACurrentMediaTime) { + self.hub = hub ?? .shared self.clock = clock } diff --git a/Apps/PointerLocker/PopupViewController.swift b/Apps/PointerLocker/PopupViewController.swift index c274393..9fca0db 100644 --- a/Apps/PointerLocker/PopupViewController.swift +++ b/Apps/PointerLocker/PopupViewController.swift @@ -5,6 +5,8 @@ import WebKit /// WebKit requires the returned web view to use the configuration it passed us. final class PopupViewController: UIViewController, WKUIDelegate { let webView: WKWebView + /// Messages about this popup's page, shown over it rather than behind it. + let toast = ToastView() init(configuration: WKWebViewConfiguration, userAgent: String?) { webView = WKWebView(frame: .zero, configuration: configuration) @@ -23,6 +25,7 @@ final class PopupViewController: UIViewController, WKUIDelegate { override func viewDidLoad() { super.viewDidLoad() + toast.install(in: view) navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) diff --git a/Apps/PointerLocker/Resources/pointerlock-polyfill.js b/Apps/PointerLocker/Resources/pointerlock-polyfill.js index 160b703..3c0e2a6 100644 --- a/Apps/PointerLocker/Resources/pointerlock-polyfill.js +++ b/Apps/PointerLocker/Resources/pointerlock-polyfill.js @@ -13,6 +13,8 @@ // page -> native: webkit.messageHandlers.pointerLocker.postMessage({type}) // {type: "lock"} page called requestPointerLock() // {type: "unlock"} lock ended (exitPointerLock, hold-Esc, detach, ...) +// {type: "webauthn"} the page asked for a passkey or security key, which +// WKWebView can't provide here (once per page) // native -> page: window.__pointerLocker.batch([[kind, ...args], ...]) // ["m", dx, dy] relative movement (CSS px, y down) // ["b", button, down] button 0 = primary, 1 = middle, 2 = secondary @@ -50,6 +52,36 @@ try { return native.element ? native.element.call(document) : null; } catch (_) { return null; } }; + // --------------------------------------------------------------------- + // Passkeys and security keys (WebAuthn). WKWebView runs WebAuthn only for + // apps with Apple's web-browser entitlement, which this app doesn't have, + // so every attempt fails. Sign-in pages that see the API keep offering it: + // Google's 2-Step Verification asks for a passkey or a phone nearby and + // fails with "make sure Bluetooth is on". Hide the API so pages offer + // another method, and if one calls it anyway, fail at once and tell the + // app, which explains what to use instead. + // --------------------------------------------------------------------- + try { delete window.PublicKeyCredential; } catch (_) {} + const credentials = navigator.credentials; + if (credentials) { + let told = false; + for (const name of ["get", "create"]) { + const original = credentials[name]; + if (typeof original !== "function") continue; + credentials[name] = function (options) { + if (!options || !options.publicKey) return original.apply(this, arguments); + // Passkey autofill ("conditional" mediation) runs quietly on page + // load; only an explicit request is worth explaining. + if (options.mediation !== "conditional" && !told) { + told = true; + post({ type: "webauthn" }); + } + return Promise.reject(new DOMException( + "Passkeys and security keys aren't available in this app.", "NotAllowedError")); + }; + } + } + // --------------------------------------------------------------------- // Browser identity. Services pick their client by device: GeForce NOW sends // an iPad (touch points, "iPad" platform) to its touch/PWA flow, which never diff --git a/Apps/PointerLocker/Services/FigmaProfile.swift b/Apps/PointerLocker/Services/FigmaProfile.swift index af3755c..ad39257 100644 --- a/Apps/PointerLocker/Services/FigmaProfile.swift +++ b/Apps/PointerLocker/Services/FigmaProfile.swift @@ -38,7 +38,7 @@ extension ServiceProfile { id: "session", symbol: "person.crop.circle.badge.checkmark", title: "Sign in with email and password", - detail: "Passkeys don't work in this app. You'll stay signed in after the first time." + detail: "Passkeys and security keys don't work in this app. If two-step sign-in asks for one, choose another way, such as a code. You'll stay signed in after the first time." ), ], wording: ServiceWording( diff --git a/Apps/PointerLocker/Services/GeForceNowProfile.swift b/Apps/PointerLocker/Services/GeForceNowProfile.swift index 4ba022c..5b2b791 100644 --- a/Apps/PointerLocker/Services/GeForceNowProfile.swift +++ b/Apps/PointerLocker/Services/GeForceNowProfile.swift @@ -39,7 +39,7 @@ extension ServiceProfile { id: "session", symbol: "person.crop.circle.badge.checkmark", title: "Sign in with a password or email code", - detail: "Passkeys don't work in this app. You'll stay signed in after the first time." + detail: "Passkeys and security keys don't work in this app. If two-step sign-in asks for one, choose another way, such as a code. You'll stay signed in after the first time." ), ], wording: ServiceWording( diff --git a/README.md b/README.md index c0f335d..26c3997 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,16 @@ builds and tests the app. ## Known risks and what to try +- **Passkeys and security keys.** WKWebView runs WebAuthn only for apps + with Apple's web-browser entitlement, which Mouselook doesn't have, so + passkeys and security keys can't work. Google's 2-Step Verification used + to offer them anyway and fail with "make sure Bluetooth is on". The + polyfill now hides `PublicKeyCredential` so sign-in pages offer another + method, and turns any WebAuthn request that still comes into an immediate + `NotAllowedError` plus a toast that says to choose another way, such as a + code from an authenticator app. The real fix would be the + `com.apple.developer.web-browser` entitlement, which requires Mouselook to + qualify as a default-browser app. - **`event.isTrusted`.** Synthetic events have `isTrusted === false`. Tested in the simulator (Sept 2026): GeForce NOW doesn't filter on it. Each `__pointerLocker.batch([["m",dx,dy]])` while locked becomes one message on diff --git a/project.yml b/project.yml index 4cafb4d..3b7ade6 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: # Change to your own team to build elsewhere. DEVELOPMENT_TEAM: AR9ZHABG8Y # IceBear s.r.o. MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "4" + CURRENT_PROJECT_VERSION: "5" targets: PointerLocker: @@ -95,6 +95,7 @@ targets: - target: PointerLocker settings: base: + TARGETED_DEVICE_FAMILY: "2" PRODUCT_BUNDLE_IDENTIFIER: sk.icebear.mouselooktests GENERATE_INFOPLIST_FILE: YES @@ -105,6 +106,7 @@ targets: - path: Apps/MouseLockPrototype settings: base: + TARGETED_DEVICE_FAMILY: "2" PRODUCT_BUNDLE_IDENTIFIER: sk.icebear.mouselockprototype info: path: Apps/MouseLockPrototype/Info.plist diff --git a/tests/PointerLockerTests/ReadinessMonitorTests.swift b/tests/PointerLockerTests/ReadinessMonitorTests.swift index cf2c18a..85936e9 100644 --- a/tests/PointerLockerTests/ReadinessMonitorTests.swift +++ b/tests/PointerLockerTests/ReadinessMonitorTests.swift @@ -1,3 +1,4 @@ +import os import XCTest @testable import PointerLocker @@ -74,13 +75,14 @@ final class ReadinessMonitorTests: XCTestCase { let screen = CGSize(width: 1024, height: 1366) monitor.update(mouseCount: 1) monitor.update(windowSize: screen, screenSize: screen) - var changes = 0 + // onChange is @Sendable, so it counts through a lock, not a captured var. + let changes = OSAllocatedUnfairLock(initialState: 0) withObservationTracking { _ = (monitor.mouse, monitor.keyboard, monitor.display) - } onChange: { changes += 1 } + } onChange: { changes.withLock { $0 += 1 } } monitor.tick(at: 100) monitor.update(windowSize: screen, screenSize: screen) monitor.update(keyboardConnected: false) - XCTAssertEqual(changes, 0) + XCTAssertEqual(changes.withLock { $0 }, 0) } } diff --git a/tests/PointerLockerTests/RootViewControllerTests.swift b/tests/PointerLockerTests/RootViewControllerTests.swift index 1d293b2..b5c39d6 100644 --- a/tests/PointerLockerTests/RootViewControllerTests.swift +++ b/tests/PointerLockerTests/RootViewControllerTests.swift @@ -24,7 +24,7 @@ final class RootViewControllerTests: XCTestCase { super.tearDown() } - func testTheSetupGuideLeavesTheGameInTheWindow() throws { + func testTheSetupGuideLeavesTheGameInTheWindow() async throws { let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.first as? UIWindowScene) window = UIWindow(windowScene: scene) let root = RootViewController() @@ -33,13 +33,12 @@ final class RootViewControllerTests: XCTestCase { let browser = try XCTUnwrap(root.browser, "a chosen service opens the browser") root.showSetupGuide() - let presented = expectation(description: "setup guide presented") - func check() { - if root.presentedViewController?.isBeingPresented == false { presented.fulfill() } - else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: check) } + // Wait for the presentation to finish, polling on the main actor. + let deadline = Date().addingTimeInterval(5) + while root.presentedViewController?.isBeingPresented != false { + guard Date() < deadline else { return XCTFail("setup guide not presented within 5 s") } + try await Task.sleep(for: .milliseconds(50)) } - check() - wait(for: [presented], timeout: 5) XCTAssertNotNil(browser.view.window, "a game under the guide must keep running, not go hidden") XCTAssertNil(root.childViewControllerForPointerLock, "the game can't take the pointer from the guide") diff --git a/tests/polyfill.test.mjs b/tests/polyfill.test.mjs index bbc5011..d25ea49 100644 --- a/tests/polyfill.test.mjs +++ b/tests/polyfill.test.mjs @@ -680,6 +680,80 @@ await test("the polyfill's own lock error still reaches the page while a native await p.close(); }); +// Passkeys and security keys: WKWebView can't run WebAuthn in this app, so +// the polyfill hides it and turns any request into a quick, explained failure. +// WebAuthn exists only in secure contexts, so serve the page from https. +async function webauthnPage(before = "") { + const p = await browser.newPage(); + await p.route("https://signin.test/**", (route) => + route.fulfill({ contentType: "text/html", body: "

sign in

" })); + await p.addInitScript({ content: noNativeLock + stub + identity + before }); + await p.addInitScript({ content: polyfill }); + await p.goto("https://signin.test/"); + return p; +} + +await test("WebAuthn is hidden from the page", async () => { + const p = await webauthnPage(); + const r = await p.evaluate(() => ({ + secure: window.isSecureContext, + type: typeof window.PublicKeyCredential, + present: "PublicKeyCredential" in window, + })); + assert.deepEqual(r, { secure: true, type: "undefined", present: false }); + await p.close(); +}); + +await test("a passkey request fails at once and tells the app, once per page", async () => { + const p = await webauthnPage(); + const r = await p.evaluate(async () => { + const publicKey = { challenge: new Uint8Array(16), timeout: 60000 }; + const errors = []; + for (const call of [() => navigator.credentials.get({ publicKey }), + () => navigator.credentials.create({ publicKey }), + () => navigator.credentials.get({ publicKey })]) { + try { await call(); errors.push("resolved"); } catch (e) { errors.push(e.name); } + } + return { errors, messages: window.__messages }; + }); + assert.deepEqual(r.errors, ["NotAllowedError", "NotAllowedError", "NotAllowedError"]); + assert.deepEqual(r.messages, [{ type: "webauthn" }]); + await p.close(); +}); + +await test("passkey autofill (conditional mediation) fails quietly", async () => { + const p = await webauthnPage(); + const r = await p.evaluate(async () => { + let error; + try { + await navigator.credentials.get({ publicKey: { challenge: new Uint8Array(16) }, mediation: "conditional" }); + } catch (e) { error = e.name; } + return { error, messages: window.__messages }; + }); + assert.equal(r.error, "NotAllowedError"); + assert.deepEqual(r.messages, []); + await p.close(); +}); + +await test("other credential requests still reach the browser", async () => { + // Stand in for the browser's own get(), installed before the polyfill wraps it. + const p = await webauthnPage(` + window.__seen = []; + CredentialsContainer.prototype.get = function (options) { + window.__seen.push(Object.keys(options)); + return Promise.resolve(null); + };`); + const r = await p.evaluate(async () => ({ + result: await navigator.credentials.get({ password: true }), + seen: window.__seen, + messages: window.__messages, + })); + assert.equal(r.result, null); + assert.deepEqual(r.seen, [["password"]]); + assert.deepEqual(r.messages, []); + await p.close(); +}); + await browser.close(); if (failures) { console.log(`\n${failures} test(s) failed`);