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
2 changes: 2 additions & 0 deletions docs/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ The web app is a progressive web app, so Chrome and Edge offer to install it to
- The service worker fetches pages from the network first and falls back to the cache when offline, so a new deployment is never stuck behind an old copy. Content-hashed bundles and assets under `/_expo/static/` and `/assets/` are cached once and reused. Bluetooth still needs the PC nearby; offline only means the app opens.
- `vercel.json` serves `sw.js` and the manifest with `no-cache`, and the hashed bundles as immutable. To change caching behaviour, change the `CACHE` name in `sw.js` so old caches are cleared on activation.

If the app has not started 10 seconds after the page loads, an inline script in `src/app/+html.tsx` shows "Switchify Remote didn't start" with a Reload button. Reload unregisters the service worker and clears its caches first, so a broken or stale copy cannot leave anyone on the loading screen. The root layout reports that the app started, which removes the message if a slow connection made it appear. It only runs in production exports.

## Switch scanning

Switch users can operate Remote with keyboard-style switch interfaces, which send key presses such as Space or Enter. Scanning uses [@switchify/scanning](https://github.com/switchifyapp/switchify-scanning), the TypeScript port of Switchify PC's item scanner, so timing, passes, groups and hold actions behave as they do on the PC.
Expand Down
43 changes: 43 additions & 0 deletions src/app/+html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,48 @@ if ('serviceWorker' in navigator && window.isSecureContext) {
});
}`;

/**
* If the app has not started 10 seconds after the page loads, offer a reload that
* also drops the service worker and its caches, so a broken or stale copy can never
* leave someone on the static loading screen. The app removes it once it starts.
*/
const startupWatchdog = `
(function () {
var started = false;
window.__switchifyStarted = function () {
started = true;
var shown = document.getElementById('switchify-start-failed');
if (shown) shown.remove();
};
function reload() {
var work = [];
if (navigator.serviceWorker) work.push(navigator.serviceWorker.getRegistrations().then(function (all) { return Promise.all(all.map(function (r) { return r.unregister(); })); }));
if (window.caches) work.push(caches.keys().then(function (keys) { return Promise.all(keys.map(function (k) { return caches.delete(k); })); }));
Promise.all(work).catch(function () {}).then(function () { location.reload(); });
}
setTimeout(function () {
if (started || !document.body) return;
var box = document.createElement('div');
box.id = 'switchify-start-failed';
box.setAttribute('role', 'alert');
box.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px;background:#0B0B0D;color:#F7F7F8;font:16px/1.5 system-ui,sans-serif;text-align:center';
var title = document.createElement('p');
title.style.cssText = 'margin:0;font-size:22px;font-weight:600';
title.textContent = 'Switchify Remote didn’t start';
var body = document.createElement('p');
body.style.cssText = 'margin:0;max-width:28em;color:#B4B4BE';
body.textContent = 'Reload to fetch a fresh copy. Your saved PCs and settings are kept.';
var button = document.createElement('button');
button.type = 'button';
button.textContent = 'Reload';
button.style.cssText = 'font:inherit;font-weight:600;min-height:52px;padding:0 32px;border:0;border-radius:12px;background:#D90429;color:#fff';
button.addEventListener('click', reload);
box.append(title, body, button);
document.body.appendChild(box);
button.focus();
}, 10000);
})();`;

export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
Expand All @@ -23,6 +65,7 @@ export default function Root({ children }: PropsWithChildren) {
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<ScrollViewStyleReset />
{process.env.NODE_ENV === 'production' ? <script dangerouslySetInnerHTML={{ __html: startupWatchdog }} /> : null}
{process.env.NODE_ENV === 'production' ? <script dangerouslySetInnerHTML={{ __html: registerServiceWorker }} /> : null}
</head>
<body>{children}</body>
Expand Down
4 changes: 3 additions & 1 deletion src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
Expand All @@ -7,13 +8,14 @@ import { ConnectionProvider } from '@/connection/ConnectionContext';
import { diagnosticsScreenOptions } from '@/navigation/diagnosticsScreenOptions';
import { FirstRunSetupGate } from '@/onboarding/FirstRunSetup';
import { StoreCaptureRoot, storeCaptureEnabled } from '@/store-capture/StoreCaptureRoot';
import { installPlatform } from '@/platform/installPlatform';
import { installPlatform, markAppStarted } from '@/platform/installPlatform';
import { ScanningProvider } from '@/scanning/ScanningProvider';
import { ThemeProvider, useTheme } from '@/theme/ThemeContext';

installPlatform();

export default function RootLayout() {
useEffect(() => { markAppStarted(); }, []);
return (
<SafeAreaProvider>
<ThemeProvider>{storeCaptureEnabled() ? <StoreCaptureRoot /> : <ScanningProvider><ThemedApp /></ScanningProvider>}</ThemeProvider>
Expand Down
2 changes: 2 additions & 0 deletions src/platform/installPlatform.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export function installPlatform(): void {}

export function markAppStarted(): void {}
11 changes: 10 additions & 1 deletion src/platform/installPlatform.web.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jest-environment jsdom */
import { AccessibilityInfo, Alert } from 'react-native';

import { installPlatform } from './installPlatform.web';
import { installPlatform, markAppStarted } from './installPlatform.web';

describe('web platform shims', () => {
beforeAll(() => installPlatform());
Expand Down Expand Up @@ -46,4 +46,13 @@ describe('web platform shims', () => {
expect(keep).toHaveBeenCalledTimes(1);
expect(unpair).toHaveBeenCalledTimes(1);
});

it('tells the startup watchdog the app has started', () => {
const started = jest.fn();
(window as Window & { __switchifyStarted?: () => void }).__switchifyStarted = started;
markAppStarted();
expect(started).toHaveBeenCalledTimes(1);
delete (window as Window & { __switchifyStarted?: () => void }).__switchifyStarted;
expect(() => markAppStarted()).not.toThrow();
Comment on lines +50 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Recovery script remains untested

The new test replaces __switchifyStarted with a mock, so it never runs startupWatchdog. All five focused tests still passed when the entire watchdog was replaced with a no-op, even though that removed the recovery screen and Reload button. This is a non-blocking coverage gap: future changes could leave users stuck on the loading screen without failing these tests. Add tests that execute the inline script and cover the 10-second delay, early and late startup, and Reload after successful or failed cleanup.

Artifacts

Executable coverage and browser validation script

  • This executed script copies tracked sources into an isolated fixture, runs both Jest conditions, and captures Chromium behavior, making the coverage check reproducible.

Original watchdog source executed in Chromium

  • The harness extracted this exact script from the PR source and verified its presence in the production export, tying the baseline browser evidence to the candidate code.

Disabled watchdog source used for the controlled mutation

  • The harness substituted this no-op in the isolated source fixture and browser response, removing recovery behavior while the focused tests still passed.

Focused Jest output with the original watchdog

  • The original-source run passed all five tests and reported zero HTML statement coverage, establishing the baseline.

Focused Jest output with the watchdog disabled

  • The mutated-source run passed all five tests despite removing recovery behavior, demonstrating that the focused suite does not protect the watchdog.

▶ Original watchdog shows recovery and Reload

  • Chromium loaded the production HTML with the app bundle blocked, waited through the real deadline, and clicked Reload, showing that baseline recovery works.

Recovery screen after the original watchdog deadline

  • The screenshot captures the original watchdog after its timer fired, with DOM assertions confirming the recovery alert and focused Reload button.

▶ Disabled watchdog leaves recovery absent

  • Chromium repeated the blocked-startup scenario with only the watchdog disabled and waited past the same deadline, showing the behavior loss that Jest missed.

No recovery screen after the disabled watchdog deadline

  • The screenshot captures the controlled mutation after the deadline, with DOM assertions confirming that no recovery alert exists.

Combined executed validation output

  • The captured run records both Jest results, exact watchdog sources, Chromium observations, cache cleanup, and restoration assertions, with exit code 0.

Tracked source and fixture restoration check

  • The final command verified an empty tracked diff and equality between the restored fixture HTML and repository HTML, confirming no permanent tracked changes.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/platform/installPlatform.web.test.ts
Line: 50-56

Comment:
**Recovery script remains untested**

The new test replaces `__switchifyStarted` with a mock, so it never runs `startupWatchdog`. All five focused tests still passed when the entire watchdog was replaced with a no-op, even though that removed the recovery screen and Reload button. This is a non-blocking coverage gap: future changes could leave users stuck on the loading screen without failing these tests. Add tests that execute the inline script and cover the 10-second delay, early and late startup, and Reload after successful or failed cleanup.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

});
});
6 changes: 6 additions & 0 deletions src/platform/installPlatform.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,9 @@ export function installPlatform(): void {
else document.addEventListener('DOMContentLoaded', () => liveRegion(), { once: true });
}
}

/** Tells the root HTML's startup watchdog that the app rendered, so it never shows its recovery message. */
export function markAppStarted(): void {
if (typeof window === 'undefined') return;
(window as Window & { __switchifyStarted?: () => void }).__switchifyStarted?.();
}
Loading