Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ store/*.png

# Business planning stays local
docs/launch-plan.md

# Local-only drafts: marketing copy, outreach, App Review mail
private/
18 changes: 2 additions & 16 deletions Apps/PointerLocker/BrowserViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}

Expand Down Expand Up @@ -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],
Expand All @@ -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
}
}
Expand Down
5 changes: 1 addition & 4 deletions Apps/PointerLocker/MouseBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
6 changes: 2 additions & 4 deletions Apps/PointerLocker/Onboarding/ReadinessMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 0 additions & 3 deletions Apps/PointerLocker/PopupViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
})
Expand Down
32 changes: 0 additions & 32 deletions Apps/PointerLocker/Resources/pointerlock-polyfill.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Apps/PointerLocker/Services/FigmaProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion Apps/PointerLocker/Services/GeForceNowProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 0 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -95,7 +95,6 @@ targets:
- target: PointerLocker
settings:
base:
TARGETED_DEVICE_FAMILY: "2"
PRODUCT_BUNDLE_IDENTIFIER: sk.icebear.mouselooktests
GENERATE_INFOPLIST_FILE: YES

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion store/shots.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 3 additions & 5 deletions tests/PointerLockerTests/ReadinessMonitorTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
import XCTest
@testable import PointerLocker

Expand Down Expand Up @@ -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)
}
}
13 changes: 7 additions & 6 deletions tests/PointerLockerTests/RootViewControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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")
Expand Down
74 changes: 0 additions & 74 deletions tests/polyfill.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>sign in</p>" }));
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`);
Expand Down
Loading