diff --git a/index.html b/index.html
index 781dabc..436593e 100644
--- a/index.html
+++ b/index.html
@@ -4,8 +4,11 @@
+
CircuitPython Code Editor
+
+
diff --git a/js/common/pwa.js b/js/common/pwa.js
new file mode 100644
index 0000000..22e9e6d
--- /dev/null
+++ b/js/common/pwa.js
@@ -0,0 +1,11 @@
+export function registerPWA() {
+ if (!import.meta.env.PROD || !("serviceWorker" in navigator)) {
+ return;
+ }
+
+ window.addEventListener("load", () => {
+ navigator.serviceWorker.register("/sw.js").catch((error) => {
+ console.warn("Unable to register service worker", error);
+ });
+ });
+}
diff --git a/js/script.js b/js/script.js
index 64906cf..6aee6e3 100644
--- a/js/script.js
+++ b/js/script.js
@@ -29,6 +29,9 @@ import { CONNTYPE } from './constants.js';
import './layout.js'; // load for side effects only
import { setupPlotterChart } from "./common/plotter.js";
import { mainContent, showSerial } from './layout.js';
+import { registerPWA } from "./common/pwa.js";
+
+registerPWA();
// Instantiate workflows
let workflows = {};
diff --git a/public/assets/images/pwa-icon-180.png b/public/assets/images/pwa-icon-180.png
new file mode 100644
index 0000000..72edfe9
Binary files /dev/null and b/public/assets/images/pwa-icon-180.png differ
diff --git a/public/assets/images/pwa-icon-192.png b/public/assets/images/pwa-icon-192.png
new file mode 100644
index 0000000..14c610f
Binary files /dev/null and b/public/assets/images/pwa-icon-192.png differ
diff --git a/public/assets/images/pwa-icon-512.png b/public/assets/images/pwa-icon-512.png
new file mode 100644
index 0000000..f4eeb51
Binary files /dev/null and b/public/assets/images/pwa-icon-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..2a16e69
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,29 @@
+{
+ "name": "CircuitPython Code Editor",
+ "short_name": "CircuitPython",
+ "description": "Edit files and use the serial console on CircuitPython devices.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#673ab7",
+ "icons": [
+ {
+ "src": "/assets/images/pwa-icon-180.png",
+ "sizes": "180x180",
+ "type": "image/png"
+ },
+ {
+ "src": "/assets/images/pwa-icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/assets/images/pwa-icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..171ba9e
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,80 @@
+const CACHE_VERSION = "circuitpython-web-editor-v1";
+const APP_SHELL = [
+ "/",
+ "/index.html",
+ "/manifest.webmanifest",
+ "/assets/index.css",
+ "/assets/checkmark.svg",
+ "/assets/js/device.js",
+ "/assets/js/index.js",
+ "/assets/fonts/fa-brands-400.woff2",
+ "/assets/fonts/fa-regular-400.woff2",
+ "/assets/fonts/fa-solid-900.woff2",
+ "/assets/fonts/fa-v4compatibility.woff2",
+ "/assets/images/favicon.ico",
+ "/assets/images/loading-blinka.gif",
+ "/assets/images/loading-blinka.webp",
+ "/assets/images/logo.png",
+ "/assets/images/logo@2x.png",
+ "/assets/images/logo@3x.png",
+ "/assets/images/pwa-icon-180.png",
+ "/assets/images/pwa-icon-192.png",
+ "/assets/images/pwa-icon-512.png"
+];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_VERSION).then((cache) => cache.addAll(APP_SHELL)),
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) => Promise.all(
+ keys
+ .filter((key) => key !== CACHE_VERSION)
+ .map((key) => caches.delete(key)),
+ )),
+ );
+});
+
+async function networkFirst(request) {
+ const cache = await caches.open(CACHE_VERSION);
+ try {
+ const response = await fetch(request);
+ cache.put(request, response.clone());
+ return response;
+ } catch (error) {
+ const cached = await cache.match(request);
+ if (cached) {
+ return cached;
+ }
+ throw error;
+ }
+}
+
+async function cacheFirst(request) {
+ const cache = await caches.open(CACHE_VERSION);
+ const cached = await cache.match(request);
+ if (cached) {
+ return cached;
+ }
+
+ const response = await fetch(request);
+ cache.put(request, response.clone());
+ return response;
+}
+
+self.addEventListener("fetch", (event) => {
+ const requestUrl = new URL(event.request.url);
+ if (event.request.method !== "GET" || requestUrl.origin !== self.location.origin) {
+ return;
+ }
+
+ if (event.request.mode === "navigate") {
+ event.respondWith(networkFirst(event.request));
+ return;
+ }
+
+ event.respondWith(cacheFirst(event.request));
+});