# A Bookmark Is Not an App.

Canonical: <https://journeytocode.io/a-bookmark-is-not-an-app/>

Make Your Rust WebAssembly Game Installable and Offline With a Manifest and a Service Worker

Last time, the game got a public address.

Open that address on a plane, in a tunnel, or on a phone with one bar, and you get a browser error page. The game runs entirely on your device. The rules are in WebAssembly. The save is in `localStorage`. And yet one dropped connection stops you from even opening it.

That is an odd thing for a game with no server to be afraid of.

Today we fix it. The game becomes installable, with its own icon and window, and it opens with no network at all.

The Rust engine does not change. Neither do the rules, saves, or clock. This lesson adds a manifest, a service worker, two icons, and one more reason to save.

## Our starting point

Everything here builds on Article 13. If you kept your project, you have the starting point already. The [`article_13` branch of the companion repository](https://github.com/journey-to-code-org/rustbound-idle/tree/article_13) has it too.

```text
rustbound-idle/
├── Cargo.toml
├── render.yaml
├── scripts/
│   ├── dev.sh
│   ├── build.sh
│   └── render-build.sh
├── src/                (unchanged today)
├── tests/              (unchanged today)
└── web/
    ├── index.html
    ├── idle-game.js
    ├── styles.css
    ├── check.mjs
    ├── package.json
    └── pkg/            (generated)
```


Article 13's release folder contains exactly five files. We are about to give it five more.

## Two kinds of offline

Rustbound already handles one kind of offline.

Close the game for an hour, come back, and the browser measures the time away. The engine runs `tick(3600)`. That is **offline progress**.

But that only works if the page opens. Opening it still needs the network to download the HTML, JavaScript, and `.wasm` file.

```text
offline progress
→ Rust works out what happened while you were away

offline loading
→ the browser starts the game without the network
```


We have the first. Now we need the second.

**Question:** What does offline loading add to the game?

1. It calculates progress while the game is closed
2. It lets the browser start the game from files already saved on the device
3. It moves the save from localStorage into WebAssembly

**Answer:** 2. It lets the browser start the game from files already saved on the device

> **Explanation:** Offline progress is already a Rust rule. Offline loading gives the browser the application files before the network disappears.


When both work, this happens:

```text
no network
    ↓
the app opens from files saved on the device
    ↓
localStorage restores the save
    ↓
JavaScript measures time away
    ↓
Rust receives tick(secondsAway)
```


Every link in that chain already exists except the second.

## A website that knows it is an app

A **Progressive Web App**, or PWA, is still a website. We add two pieces:

1. A **manifest** tells the browser the app's name, icons, colors, and starting page.
2. A **service worker** can answer requests with files saved earlier.

No app store. No wrapper. No second game.

## The manifest

Create `web/manifest.webmanifest`:

**web/manifest.webmanifest**

```json
{
  "id": "./",
  "name": "Rustbound Idle",
  "short_name": "Rustbound",
  "description": "A tiny idle clicker powered by a Rust game engine compiled to WebAssembly.",
  "start_url": "./",
  "scope": "./",
  "display": "standalone",
  "background_color": "#171a18",
  "theme_color": "#171a18",
  "icons": [
    { "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
```


A few fields do most of the work:

- `start_url` is the page the installed app opens.
- `scope` is the part of the site the app owns.
- `display: "standalone"` asks for an app window instead of a normal browser tab.
- `icons` gives the browser images for a home screen, launcher, or taskbar.
- `id` is the app's stable identity. If `start_url` ever changes, the browser still knows this is the same installed app.

Add the two square PNG files:

```text
web/icons/icon-192.png
web/icons/icon-512.png
```


The companion branch includes the Rustbound icons used by the live game. They are ordinary PNG files. The browser does not need the source artwork.

Now tell the page where to find the manifest. Add this inside `<head>`, after `<title>`:

**web/index.html**

```html
<meta name="theme-color" content="#171a18" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Rustbound" />

<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/icon-192.png" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
```


The manifest link is the important line. The other metadata helps browsers place the right color, name, and icon around the installed page.

## The service worker

A service worker is JavaScript that runs separately from the page. Once it controls the page, it can sit between a request and the network.

Ours has one boring job:

> Keep copies of the files the game needs to start, and use them when the network fails.

No combat. No XP. No saves. If game rules appear in this file, they have wandered into the wrong building.

Create `web/sw.js`. Start with the cache name and the **app shell**, the files needed to open the game:

**web/sw.js**

```js
const CACHE_NAME = "rustbound-idle-v1";

const APP_SHELL = [
  "./",
  "./index.html",
  "./styles.css",
  "./idle-game.js",
  "./pwa.js",
  "./manifest.webmanifest",
  "./icons/icon-192.png",
  "./icons/icon-512.png",
  "./pkg/rustbound_idle.js",
  "./pkg/rustbound_idle_bg.wasm",
];
```


The `"./"` entry and `index.html` point to the same file through two addresses. People visit the first address. The manifest names it too. We cache both.

### Install: save the shell

```js
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
  );

  self.skipWaiting();
});
```


`cache.addAll()` is all-or-nothing. If one file cannot be downloaded, the worker does not finish installing.

That is what we want. A half-saved game would open just far enough to disappoint us.

**Question:** What happens if one file in `APP_SHELL` is missing from the deployed site?

1. The browser creates an empty replacement file
2. Only that file is skipped
3. `cache.addAll()` rejects, so the service worker does not finish installing

**Answer:** 3. `cache.addAll()` rejects, so the service worker does not finish installing

> **Explanation:** The shell is one unit. Every named request must succeed before the worker can claim it is ready for offline use.


### Activate: remove old copies

```js
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(
          keys
            .filter((key) => key !== CACHE_NAME)
            .map((key) => caches.delete(key)),
        ),
      )
      .then(() => self.clients.claim()),
  );
});
```


When a new worker takes over, it deletes caches with old names. Then `clients.claim()` lets it control open pages instead of waiting for another visit.

### Fetch: try the network, then the cache

```js
self.addEventListener("fetch", (event) => {
  const request = event.request;

  if (request.method !== "GET") return;

  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;

  event.respondWith(networkFirst(request));
});

async function networkFirst(request) {
  const cache = await caches.open(CACHE_NAME);

  try {
    const response = await fetch(request);

    if (response.ok) {
      await cache.put(request, response.clone());
    }

    return response;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached;

    if (request.mode === "navigate") {
      const fallback = await cache.match("./index.html");
      if (fallback) return fallback;
    }

    return Response.error();
  }
}
```


Read `networkFirst()` as a list:

1. Try the network.
2. If it works, save a fresh copy and return it.
3. If it fails, return the saved copy.
4. For an uncached page address, fall back to `index.html`.
5. If nothing can answer the request, fail honestly.

Network first keeps updates simple. The trade-off is that a connection can be alive and painfully slow. The browser waits for it to fail before using the cache. A larger app may choose another strategy. Our ten-file game can start here.

## Register it

A service worker does nothing until the page registers it. Create `web/pwa.js`:

**web/pwa.js**

```js
if ("serviceWorker" in navigator) {
  window.addEventListener("load", async () => {
    try {
      const registration = await navigator.serviceWorker.register("./sw.js", {
        scope: "./",
      });

      console.info("Rustbound Idle service worker ready:", registration.scope);
    } catch (error) {
      console.warn("Rustbound Idle could not enable offline mode.", error);
    }
  });
}
```


Load it under the game's module in `index.html`:

```html
<script type="module" src="./idle-game.js"></script>
<script type="module" src="./pwa.js"></script>
```


If service workers are unavailable or registration fails, the game still works as a website. The game never waits for the worker. Offline loading is a bonus layered on top, not a gate the engine has to pass through.

Service workers require a secure context. The public game has HTTPS, and browsers treat `localhost` as secure for development.

## An installed game is hidden, not closed

Article 12 saved during `beforeunload`. That can help when a browser tab closes, but mobile systems may hide a page and later discard it without firing that event.

The better signal for saving state is `visibilitychange`. It fires when the document becomes hidden, including when the player switches tabs or moves to another app.

Add this under the existing `beforeunload` listener in `connectedCallback()`:

**web/idle-game.js**

```js
window.addEventListener("beforeunload", () => this.saveGame());

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") this.saveGame();
});
```


We keep the old listener and add the earlier opportunity. The save format, timestamp, and eight-hour cap do not change.

**Question:** Why save when `document.visibilityState` becomes `hidden`?

1. Hidden pages lose access to localStorage
2. A mobile browser may discard a backgrounded page without firing `beforeunload`
3. Service workers can only read saves from hidden pages

**Answer:** 2. A mobile browser may discard a backgrounded page without firing `beforeunload`

> **Explanation:** Becoming hidden is the last event a page can depend on observing. Save there instead of assuming a later close event will arrive.


## Two lists must agree

The service worker promises to cache a list. The release script promises to ship a list.

Those lists now share a contract.

Update the release folder in `scripts/build.sh`:

**scripts/build.sh**

```bash
rm -rf dist
mkdir -p dist/pkg dist/icons

cp web/index.html web/idle-game.js web/styles.css dist/
cp web/pwa.js web/sw.js web/manifest.webmanifest dist/
cp web/icons/icon-192.png web/icons/icon-512.png dist/icons/
cp web/pkg/rustbound_idle.js web/pkg/rustbound_idle_bg.wasm dist/pkg/
```


`sw.js` itself ships, but it is not in `APP_SHELL`. The browser fetches and updates the worker separately. The other nine physical files must ship and be cached. The `"./"` cache entry is a second address for `index.html`, not an eleventh file.

Try the contract below. Turn off either side of a file and check the release. The worker can only keep a file the build actually shipped.

**HTML**

```html
<h2>Offline release contract</h2>
<p>Toggle whether each file ships and whether the app shell caches it.</p>
<div class="files"></div>
<button id="check" type="button">Check release</button>
<pre aria-live="polite"></pre>
```

**CSS**

```css
body { font: 16px/1.45 system-ui; max-width: 52rem; margin: 1.5rem auto; padding: 0 1rem; color: #f5f1e8; background: #15171c; }
.files { display: grid; gap: .5rem; margin: 1rem 0; }
.row { display: grid; grid-template-columns: minmax(12rem, 1fr) auto auto; gap: .5rem; align-items: center; padding: .55rem; background: #20242b; }
button { min-height: 44px; border: 1px solid #777; border-radius: .3rem; padding: .55rem .75rem; color: inherit; background: #292e37; }
button[aria-pressed="true"] { border-color: #ff6b3d; background: #48271d; }
#check { width: 100%; background: #ff5522; border-color: #ff5522; color: #111; font-weight: 700; }
pre { min-height: 5rem; padding: 1rem; white-space: pre-wrap; background: #0d0f13; }
@media (max-width: 34rem) { .row { grid-template-columns: 1fr 1fr; } .name { grid-column: 1 / -1; } }
```

**JavaScript**

```js
const files = [
  ["index.html", true, true],
  ["idle-game.js", true, true],
  ["styles.css", true, true],
  ["pwa.js", true, true],
  ["sw.js", true, false],
  ["manifest.webmanifest", true, true],
  ["icons/icon-192.png", true, true],
  ["icons/icon-512.png", true, true],
  ["pkg/rustbound_idle.js", true, true],
  ["pkg/rustbound_idle_bg.wasm", true, true],
].map(([name, shipped, cached]) => ({ name, shipped, cached }));

const list = document.querySelector(".files");
const output = document.querySelector("pre");

for (const file of files) {
  const row = document.createElement("div");
  row.className = "row";
  const name = document.createElement("code");
  name.className = "name";
  name.textContent = file.name;
  row.append(name, toggle(file, "shipped", "Ships"), toggle(file, "cached", "Cached"));
  list.append(row);
}

function toggle(file, key, label) {
  const button = document.createElement("button");
  button.type = "button";
  button.textContent = label;
  button.setAttribute("aria-label", `${label}: ${file.name}`);
  button.setAttribute("aria-pressed", file[key]);
  button.addEventListener("click", () => {
    file[key] = !file[key];
    button.setAttribute("aria-pressed", file[key]);
  });
  return button;
}

document.querySelector("#check").addEventListener("click", () => {
  const missing = files.filter((file) => file.cached && !file.shipped);
  const uncached = files.filter((file) => file.name !== "sw.js" && file.shipped && !file.cached);
  const workerCached = files.find((file) => file.name === "sw.js").cached;
  const problems = [
    ...missing.map((file) => `${file.name}: cached but not shipped`),
    ...uncached.map((file) => `${file.name}: shipped but not cached`),
    ...(workerCached ? ["sw.js: the browser updates the worker separately"] : []),
  ];

  output.textContent = problems.length
    ? `CONTRACT BROKEN\n${problems.join("\n")}`
    : "CONTRACT HOLDS\nTen files ship. Nine physical app files are cached.";
});

document.querySelector("#check").click();
```

[Keep the release and app shell in agreement on the canonical article](https://journeytocode.io/a-bookmark-is-not-an-app/).


The complete folder is:

```text
dist/
├── index.html
├── idle-game.js
├── styles.css
├── pwa.js
├── sw.js
├── manifest.webmanifest
├── icons/
│   ├── icon-192.png
│   └── icon-512.png
└── pkg/
    ├── rustbound_idle.js
    └── rustbound_idle_bg.wasm
```


Ten files ship. Nine physical files belong to the shell. Two lists. One tiny exception that is now written down.

## Test offline by removing the server

Turning off Wi-Fi once and nodding at the screen is not a test.

We need to serve `dist/`, load the game, wait for the service worker, shut the server down, and reload.

Create `web/check-offline.mjs`:

**web/check-offline.mjs**

```js
import { createReadStream, statSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, resolve } from "node:path";
import { chromium } from "playwright";

const DIST = resolve(process.argv[2] ?? "../dist");
const PORT = 8081;
const ORIGIN = `http://localhost:${PORT}/`;

const TYPES = {
  ".html": "text/html",
  ".js": "text/javascript",
  ".css": "text/css",
  ".wasm": "application/wasm",
  ".webmanifest": "application/manifest+json",
  ".png": "image/png",
};

function serve() {
  const server = createServer((req, res) => {
    const path = new URL(req.url, ORIGIN).pathname;
    const file = join(DIST, path.endsWith("/") ? `${path}index.html` : path);

    if (!statSync(file, { throwIfNoEntry: false })?.isFile()) {
      res.writeHead(404).end();
      return;
    }

    res.writeHead(200, {
      "content-type": TYPES[extname(file)] ?? "application/octet-stream",
    });
    createReadStream(file).pipe(res);
  });

  return new Promise((done) => server.listen(PORT, () => done(server)));
}

let failures = 0;

function check(name, ok) {
  console.log(`${ok ? "PASS" : "FAIL"}  ${name}`);
  if (!ok) failures += 1;
}

const server = await serve();
const browser = await chromium.launch();

try {
  const page = await browser.newPage();
  const gameStarted = () => Boolean(document.querySelector("idle-game")?.game);

  await page.goto(ORIGIN);
  await page.waitForFunction(gameStarted);
  check("the game starts while the server is up", true);

  await page.locator("idle-game").locator('[data-action="attack"]').click();

  await page.waitForFunction(() => navigator.serviceWorker.controller, null, {
    timeout: 10000,
  });
  check("a service worker controls the page", true);

  server.close();
  server.closeAllConnections();

  await page.reload();
  await page.waitForFunction(gameStarted);
  check("the game starts again with the server gone", true);

  const stage = await page.locator("idle-game").locator("[data-stage]").textContent();
  check("the game draws its state", stage.trim() !== "");

  const saved = await page.evaluate(() =>
    localStorage.getItem("rustbound-idle.save"),
  );
  check("the save is still there", saved !== null);
} catch (error) {
  console.error(error.message);
  failures += 1;
} finally {
  await browser.close();
  server.close();
}

process.exit(failures === 0 ? 0 : 1);
```


The click before the server closes is not decoration. It calls the real component's `saveGame()`, so the final save assertion does not depend on a timer winning a race.

Add the script next to Article 13's check in `web/package.json`:

**web/package.json**

```json
"scripts": {
  "check": "node check.mjs",
  "check:offline": "node check-offline.mjs"
}
```


Build and run it:

```bash
bash ./scripts/build.sh
cd web
npm run check:offline
```


This is the real output from the Article 14 release:

```text
PASS  the game starts while the server is up
PASS  a service worker controls the page
PASS  the game starts again with the server gone
PASS  the game draws its state
PASS  the save is still there
```


Step three is the point. The server is gone. The game is not.

### Prove the test can fail

A test that cannot fail tells us nothing.

I removed the `pwa.js` line from the generated `dist/index.html`. The game started online, then the test waited ten seconds for a worker that never arrived:

```text
PASS  the game starts while the server is up
page.waitForFunction: Timeout 10000ms exceeded.
```


Then I restored that line and removed `pkg/rustbound_idle.js` from the generated app shell. The worker took control, but the offline reload could not build the game:

```text
PASS  the game starts while the server is up
PASS  a service worker controls the page
page.waitForFunction: Timeout 30000ms exceeded.
```


Both edits were made only in generated `dist/`. The next build restores the release.

The second experiment also caught a bug in the first version of this test. It used this condition:

```js
element?.shadowRoot?.querySelector("[data-enemy-health]")?.textContent !== "0"
```


If the element had not loaded, the left side was `undefined`. Unfortunately, `undefined !== "0"` is `true`. The broken game looked ready.

The fixed check waits for the property the component sets only after WebAssembly initializes and the save is restored:

```js
Boolean(document.querySelector("idle-game")?.game)
```


The same condition had been living in Article 13's `check.mjs` the whole time, passing for the same wrong reason. Both files now wait for `.game`.

The negative test improved the test. That seems fair.

## Install it

Push the checkpoint and let Render rebuild. Open the live game once while online so the service worker can install its shell.

Browsers place installation in different spots. Chrome and Edge may show an icon near the address bar or an install item in the menu. On iPhone and iPad, installation goes through **Share → Add to Home Screen**.

The exact prompt is browser UI, not part of our game. The manifest supplies the name, icon, colors, starting page, and standalone display request. The browser decides how to offer them.

Once the site has loaded online, the useful test is less glamorous:

1. Click the enemy once. That click saves, so the reload has something to restore.
2. Disconnect the network.
3. Reload or reopen the game.

The service worker opens the application. `localStorage` restores the save. JavaScript measures the time away. Rust applies `tick(secondsAway)`.

```text
service worker   → the game opens
localStorage     → the save comes back
JavaScript       → measures time away
Rust             → tick(secondsAway)
```


## Shipping an update

A player with the app installed already has files cached.

Our network-first strategy refreshes successful requests while the player is online. But a release can change several cached files together. We do not want half of one version and half of another pretending they have met.

Whenever a cached file changes, bump the cache name:

```js
const CACHE_NAME = "rustbound-idle-v2";
```


The new worker installs the new shell as one unit. During activation, it removes the old cache. Players already on the old version pick up the new one on their next reload; nothing swaps out from under a running game.

The saved game stays where it was. Cache Storage holds application files. `localStorage` holds the player's save. They are neighbors, not roommates.

## The complete checkpoint

Article 14 changes no Rust. It adds the browser's offline layer around the same engine:

```text
rustbound-idle/
├── scripts/build.sh       (ships ten named files)
├── src/                   (unchanged)
├── tests/                 (unchanged)
└── web/
    ├── check.mjs          (fixes the readiness check)
    ├── check-offline.mjs
    ├── icons/
    │   ├── icon-192.png
    │   └── icon-512.png
    ├── idle-game.js       (saves when hidden)
    ├── index.html         (manifest and PWA metadata)
    ├── manifest.webmanifest
    ├── package.json       (adds check:offline)
    ├── pwa.js
    └── sw.js
```


The [`article_14` branch](https://github.com/journey-to-code-org/rustbound-idle/tree/article_14) holds the tested checkpoint.

Run the gates one more time:

```bash
cargo test
bash ./scripts/build.sh
cd web
npm run check:offline
```


Thirty-two Rust tests. Seven game checks. Five offline checks. Ten files to deploy.

## What we learned

- A **PWA** adds install metadata and an offline request layer to a website.
- A **manifest** names the app, its icons, its start page, and how it should display.
- A **service worker** can answer failed network requests from Cache Storage.
- The app shell is one unit: `cache.addAll()` installs it all or not at all, and the build allowlist must ship every file it names.
- `visibilitychange` gives the page a dependable moment to save before it may be discarded.
- An offline test should remove the server, not merely toggle a browser setting.
- A negative control can expose a test that was passing for the wrong reason.

You do not need to memorize the service worker lifecycle yet.

The important boundary is smaller. Rust still owns what happens in the game. The browser now knows how to keep the game available.

## Next: the save trusts us too much

The game opens offline and restores its save. There is a problem, though.

That save is a string in `localStorage`. A player can edit it and hand themselves a billion gold. The Rust engine will load whatever it is given.

Next time, we decide what the game should trust.

### Further reading

- [Making PWAs installable](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable)
- [Web app manifests](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest)
- [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
- [`visibilitychange`](https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event)

**Small function. Bigger game.**
[Join the discussion on the canonical article](https://journeytocode.io/a-bookmark-is-not-an-app/)
