diff --git a/.gitignore b/.gitignore index 89b8b3c..e0e36a8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ store/*.png # Business planning stays local docs/launch-plan.md + +# Local-only drafts: marketing copy, outreach, App Review mail +private/ diff --git a/Apps/PointerLocker/BrowserViewController.swift b/Apps/PointerLocker/BrowserViewController.swift index 9a1d3f0..0509db2 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 - MainActor.assumeIsolated { self?.sessionKeeper.didEnterBackground() } + self?.sessionKeeper.didEnterBackground() }) observers.append(center.addObserver(forName: UIScene.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.sessionKeeper.willEnterForeground() } + self?.sessionKeeper.willEnterForeground() }) } @@ -348,12 +348,6 @@ 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], @@ -367,14 +361,6 @@ 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 b33db37..d89f2a8 100644 --- a/Apps/PointerLocker/MouseBridge.swift +++ b/Apps/PointerLocker/MouseBridge.swift @@ -53,10 +53,7 @@ final class MouseBridge { private let hub: MouseEventHub private var hubToken: MouseEventHub.Token? - /// `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 + init(usesDisplayLink: Bool = true, hub: MouseEventHub = .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 626e6a6..566177a 100644 --- a/Apps/PointerLocker/Onboarding/ReadinessMonitor.swift +++ b/Apps/PointerLocker/Onboarding/ReadinessMonitor.swift @@ -27,10 +27,8 @@ final class ReadinessMonitor { @ObservationIgnored private var observers: [NSObjectProtocol] = [] @ObservationIgnored private var timer: Timer? - /// `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 + init(hub: MouseEventHub = .shared, clock: @escaping () -> TimeInterval = CACurrentMediaTime) { + self.hub = hub self.clock = clock } diff --git a/Apps/PointerLocker/PopupViewController.swift b/Apps/PointerLocker/PopupViewController.swift index 9fca0db..c274393 100644 --- a/Apps/PointerLocker/PopupViewController.swift +++ b/Apps/PointerLocker/PopupViewController.swift @@ -5,8 +5,6 @@ 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) @@ -25,7 +23,6 @@ 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 3c0e2a6..160b703 100644 --- a/Apps/PointerLocker/Resources/pointerlock-polyfill.js +++ b/Apps/PointerLocker/Resources/pointerlock-polyfill.js @@ -13,8 +13,6 @@ // 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 @@ -52,36 +50,6 @@ 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 ad39257..af3755c 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 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." + detail: "Passkeys don't work in this app. 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 5b2b791..4ba022c 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 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." + detail: "Passkeys don't work in this app. You'll stay signed in after the first time." ), ], wording: ServiceWording( diff --git a/README.md b/README.md index 26c3997..c0f335d 100644 --- a/README.md +++ b/README.md @@ -247,16 +247,6 @@ 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 3b7ade6..4cafb4d 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: "5" + CURRENT_PROJECT_VERSION: "4" targets: PointerLocker: @@ -95,7 +95,6 @@ targets: - target: PointerLocker settings: base: - TARGETED_DEVICE_FAMILY: "2" PRODUCT_BUNDLE_IDENTIFIER: sk.icebear.mouselooktests GENERATE_INFOPLIST_FILE: YES @@ -106,7 +105,6 @@ 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/store/shots.json b/store/shots.json index 0cd971f..c3029f7 100644 --- a/store/shots.json +++ b/store/shots.json @@ -11,7 +11,7 @@ "id": "02-context-menu", "raw": "figma-context-menu.png", "title": "Right-click and shortcuts just work", - "subtitle": "Context menus, modifier keys and Figma's keyboard shortcuts." + "subtitle": "Context menus, modifier keys and keyboard shortcuts." }, { "id": "03-scrub", diff --git a/tests/PointerLockerTests/ReadinessMonitorTests.swift b/tests/PointerLockerTests/ReadinessMonitorTests.swift index 85936e9..cf2c18a 100644 --- a/tests/PointerLockerTests/ReadinessMonitorTests.swift +++ b/tests/PointerLockerTests/ReadinessMonitorTests.swift @@ -1,4 +1,3 @@ -import os import XCTest @testable import PointerLocker @@ -75,14 +74,13 @@ final class ReadinessMonitorTests: XCTestCase { let screen = CGSize(width: 1024, height: 1366) monitor.update(mouseCount: 1) monitor.update(windowSize: screen, screenSize: screen) - // onChange is @Sendable, so it counts through a lock, not a captured var. - let changes = OSAllocatedUnfairLock(initialState: 0) + var changes = 0 withObservationTracking { _ = (monitor.mouse, monitor.keyboard, monitor.display) - } onChange: { changes.withLock { $0 += 1 } } + } onChange: { changes += 1 } monitor.tick(at: 100) monitor.update(windowSize: screen, screenSize: screen) monitor.update(keyboardConnected: false) - XCTAssertEqual(changes.withLock { $0 }, 0) + XCTAssertEqual(changes, 0) } } diff --git a/tests/PointerLockerTests/RootViewControllerTests.swift b/tests/PointerLockerTests/RootViewControllerTests.swift index b5c39d6..1d293b2 100644 --- a/tests/PointerLockerTests/RootViewControllerTests.swift +++ b/tests/PointerLockerTests/RootViewControllerTests.swift @@ -24,7 +24,7 @@ final class RootViewControllerTests: XCTestCase { super.tearDown() } - func testTheSetupGuideLeavesTheGameInTheWindow() async throws { + func testTheSetupGuideLeavesTheGameInTheWindow() throws { let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.first as? UIWindowScene) window = UIWindow(windowScene: scene) let root = RootViewController() @@ -33,12 +33,13 @@ final class RootViewControllerTests: XCTestCase { let browser = try XCTUnwrap(root.browser, "a chosen service opens the browser") root.showSetupGuide() - // 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)) + 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) } } + 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 d25ea49..bbc5011 100644 --- a/tests/polyfill.test.mjs +++ b/tests/polyfill.test.mjs @@ -680,80 +680,6 @@ 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`);