From 9daf250be054ceec13873152709139c25ce5cb16 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 27 Sep 2026 21:17:23 +0800 Subject: [PATCH] Make isketch installable, offline, and open files and shared text A build-generated service worker caches the app shell. The manifest adds file handlers for .flow and a share target, which open as undoable changes. --- README.md | 3 + backlog/tasks/fl-82 - Installable.md | 42 +++++++++-- docker/nginx.conf | 5 ++ e2e/installable.spec.js | 93 ++++++++++++++++++++++++ playwright.config.js | 2 + public/manifest.webmanifest | 37 ++++++++-- src/composables/useDiagramFile.js | 2 +- src/composables/useLaunch.js | 64 +++++++++++++++++ src/domain/__tests__/sharedText.spec.js | 32 +++++++++ src/domain/sharedText.js | 22 ++++++ src/main.js | 9 +++ src/pwa/__tests__/serviceWorker.spec.js | 34 +++++++++ src/pwa/serviceWorker.js | 96 +++++++++++++++++++++++++ src/views/FlowView.vue | 2 + vite.config.js | 18 +++++ 15 files changed, 451 insertions(+), 10 deletions(-) create mode 100644 e2e/installable.spec.js create mode 100644 src/composables/useLaunch.js create mode 100644 src/domain/__tests__/sharedText.spec.js create mode 100644 src/domain/sharedText.js create mode 100644 src/pwa/__tests__/serviceWorker.spec.js create mode 100644 src/pwa/serviceWorker.js diff --git a/README.md b/README.md index 5399c65..8360058 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief and Eraser (`7`/`E`, click a shape or connection to delete it). Escape goes back to Select. - **New diagram and samples.** Start empty, or from a web app architecture or support flow sample. Undo brings back whatever was there. +- **Installable.** Install isketch from the browser as an app that opens with no connection. The + installed app opens `.flow` files from the file manager, and takes text shared to it from other + apps (`.flow` text or a Mermaid flowchart) as a new diagram, undoable. - **Phones and tablets.** Below 768px the tool bar docks at the bottom and panels and dialogs rise as sheets. Pinch to zoom and pan with two fingers anywhere, even over shapes; a long press (or a right click) opens a shape's or connection's menu. A stylus draws with its pressure, and a palm diff --git a/backlog/tasks/fl-82 - Installable.md b/backlog/tasks/fl-82 - Installable.md index ea17e39..097e36a 100644 --- a/backlog/tasks/fl-82 - Installable.md +++ b/backlog/tasks/fl-82 - Installable.md @@ -1,9 +1,11 @@ --- id: FL-82 title: Installable -status: To Do -assignee: [] +status: Done +assignee: + - '@raj-khan' created_date: '2026-09-25 17:39' +updated_date: '2026-09-27 13:17' labels: - frontend - pwa @@ -26,8 +28,38 @@ Make isketch a PWA so it installs, works offline, and opens .flow files from the -- [ ] #1 Installable, with the app shell cached for offline use -- [ ] #2 Opens .flow files from the OS via file handlers -- [ ] #3 Accepts shared text as a new diagram via a share target +- [x] #1 Installable, with the app shell cached for offline use +- [x] #2 Opens .flow files from the OS via file handlers +- [x] #3 Accepts shared text as a new diagram via a share target + +## Implementation Plan + + + +1. Manifest: start_url /new, file_handlers for .flow, a GET share_target at /new with title, text, url. +2. A hand-rolled service worker written by a Vite plugin at build, listing the hashed bundle and public shell files: navigations network first with the cached app or landing as fallback, same-origin assets cache first, cross-origin untouched. Registered in production only. +3. useLaunchFiles: launchQueue consumer opens a launched .flow file through the same path as Open (undoable, remembers the handle). +4. useSharedText: /new?text=... opens shared .flow text (or Mermaid) as an undoable change and clears the query. +5. e2e: service worker caches the shell and /new loads offline; ?text= opens a shared diagram; a simulated launchQueue opens a file. + + + +## Implementation Notes + + + +Service worker is hand rolled (src/pwa/serviceWorker.js), written by a post-enforced Vite plugin so the HTML pages are in the bundle it lists. Cache lookups ignore Vary: a module import sends Origin and the install did not, so without it the lazy drawer chunk missed the cache offline. + +launch_handler focus-existing was left out: it would hand a share to an open window through the launch queue instead of navigating to ?text=. Playwright blocks service workers except in the offline spec, so a cached build never outlives a rebuild in tests. nginx serves /sw.js no-cache. + +Verified: e2e/installable.spec.js (6): offline reload of /new and a deep node route with the worker, CDP Page.getInstallabilityErrors [] and no manifest errors, manifest file_handlers and share_target, ?text= opens (undoable) and non-diagrams are refused, a stubbed launchQueue opens a .flow file. Unit tests for readSharedText and the worker source. e2e 125, vitest 254, lint and typecheck. + + +## Final Summary + + + +isketch installs as a PWA that opens offline (a build-generated service worker caches the shell), opens .flow files from the system through file handlers, and takes shared .flow or Mermaid text as a new, undoable diagram through a share target. Verified with Chrome installability checks, offline e2e and launch/share e2e. + diff --git a/docker/nginx.conf b/docker/nginx.conf index 9364410..d60f637 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -19,6 +19,11 @@ server { add_header Cache-Control "public, immutable"; } + # The service worker checks for a new build on every visit. + location = /sw.js { + add_header Cache-Control "no-cache"; + } + # Holds the build's analytics setting, under a name that never changes. location = /analytics.js { add_header Cache-Control "no-cache"; diff --git a/e2e/installable.spec.js b/e2e/installable.spec.js new file mode 100644 index 0000000..6d147fa --- /dev/null +++ b/e2e/installable.spec.js @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test' + +const shapes = (page) => page.locator('.vue-flow__node') + +test.describe('offline', () => { + test.use({ serviceWorkers: 'allow' }) + + test('the service worker caches the app, which then opens with no connection', async ({ + page, + context, + }) => { + await page.goto('/new') + await expect(shapes(page)).toHaveCount(5) + await page.evaluate(() => navigator.serviceWorker.ready) + // Controlled from the next load on; that load also fills the cache. + await page.reload() + await expect + .poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))) + .toBe(true) + + await context.setOffline(true) + await page.reload() + await expect(shapes(page)).toHaveCount(5) + await page.goto('/new/node/b6a0c1') + await expect(page.getByLabel('Title')).toHaveValue('Away Message') + }) + + test('Chrome finds it installable, with a manifest it has no complaint about', async ({ + page, + context, + }) => { + await page.goto('/new') + await page.evaluate(() => navigator.serviceWorker.ready) + const cdp = await context.newCDPSession(page) + expect((await cdp.send('Page.getInstallabilityErrors')).installabilityErrors).toEqual([]) + expect((await cdp.send('Page.getAppManifest')).errors).toEqual([]) + }) +}) + +test('the manifest makes it installable, opening .flow files and taking shared text', async ({ + request, +}) => { + const manifest = await (await request.get('/manifest.webmanifest')).json() + expect(manifest).toMatchObject({ start_url: '/new', display: 'standalone' }) + expect(manifest.file_handlers[0].accept['text/plain']).toContain('.flow') + expect(manifest.share_target).toMatchObject({ action: '/new', method: 'GET' }) + expect(manifest.icons.some((icon) => icon.sizes === '512x512')).toBe(true) +}) + +test('text shared to the app opens as a diagram, and undo brings the old one back', async ({ + page, +}) => { + const text = 'pay = process "Pay"\nship = process "Ship"\npay -> ship' + await page.goto(`/new?title=Checkout&text=${encodeURIComponent(text)}`) + await expect(shapes(page)).toHaveCount(2) + await expect(page).toHaveURL(/\/new$/) + await expect(page.getByText('Opened what was shared.')).toBeVisible() + + await page.getByRole('button', { name: 'Undo' }).last().click() + await expect(shapes(page)).toHaveCount(5) +}) + +test('shared text that is not a diagram says so, and changes nothing', async ({ page }) => { + await page.goto('/new?text=Look%20at%20this') + await expect(page.getByText('What was shared is not a diagram isketch can read.')).toBeVisible() + await expect(shapes(page)).toHaveCount(5) +}) + +test('a .flow file opened with the app from the system opens as a diagram', async ({ page }) => { + // The system's launch queue, as Chrome gives an installed app a file it opens. + await page.addInitScript(() => { + // Chrome has its own, which a plain assignment would not replace. + Object.defineProperty(window, 'launchQueue', { + configurable: true, + value: { setConsumer: (consumer) => (window.__launch = consumer) }, + }) + }) + await page.goto('/new') + await expect(shapes(page)).toHaveCount(5) + + await page.evaluate(() => + window.__launch({ + files: [ + { + getFile: async () => + new File(['title: Launched\na = process "A"\nb = process "B"'], 'launched.flow'), + }, + ], + }), + ) + await expect(shapes(page)).toHaveCount(2) + await expect(page.getByText('Opened launched.flow')).toBeVisible() +}) diff --git a/playwright.config.js b/playwright.config.js index 769955a..dae72af 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -12,6 +12,8 @@ export default defineConfig({ reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']], use: { + // The offline spec turns it on; elsewhere a cached build would outlive a rebuild. + serviceWorkers: 'block', baseURL: 'http://localhost:4173', trace: 'on-first-retry', }, diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 22ead17..f69372f 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -2,25 +2,54 @@ "name": "isketch — sketch it, hand it to your agent", "short_name": "isketch", "description": "A sketchpad for architecture and UI diagrams whose output is agent-ready: every sketch is also .flow text a coding agent reads exactly, and can edit back.", - "start_url": "/", + "start_url": "/new", "scope": "/", "display": "standalone", "background_color": "#0d1117", "theme_color": "#0d1117", "icons": [ - { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, { "src": "/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, - { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, { "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } - ] + ], + "id": "/new", + "file_handlers": [ + { + "action": "/new", + "accept": { + "text/plain": [".flow"] + } + } + ], + "share_target": { + "action": "/new", + "method": "GET", + "enctype": "application/x-www-form-urlencoded", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + } } diff --git a/src/composables/useDiagramFile.js b/src/composables/useDiagramFile.js index fdfb933..f6e82b8 100644 --- a/src/composables/useDiagramFile.js +++ b/src/composables/useDiagramFile.js @@ -136,7 +136,7 @@ export function useDiagramFile({ bindKeys = false } = {}) { onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown, true)) } - return { open, save, fileName: () => file.name } + return { open, save, openText: load, fileName: () => file.name } } /** @param {any} handle @param {string} text */ diff --git a/src/composables/useLaunch.js b/src/composables/useLaunch.js new file mode 100644 index 0000000..7909e81 --- /dev/null +++ b/src/composables/useLaunch.js @@ -0,0 +1,64 @@ +import { watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +import { useDiagramFile } from '@/composables/useDiagramFile.js' +import { useFlowHistory } from '@/composables/useFlowHistory.js' +import { useFlowQuery } from '@/composables/useFlowQuery.js' +import { useReplaceDocument } from '@/composables/useNodeMutations.js' +import { readSharedText } from '@/domain/sharedText.js' +import { ROUTE } from '@/router/index.js' +import { useCanvasStore } from '@/stores/canvas.js' +import { useFileStore } from '@/stores/file.js' +import { useToastStore } from '@/stores/toasts.js' + +/** + * How the installed app is opened by the system: a `.flow` file opened with + * isketch (file handlers), or text shared to it (the share target sends it + * as `?text=`). Either opens as an undoable change, once the diagram saved + * here has loaded, so undo can bring that one back. + */ +export function useLaunch() { + const route = useRoute() + const router = useRouter() + const { isLoading } = useFlowQuery() + const { openText } = useDiagramFile() + const replace = useReplaceDocument('Open shared text') + const { undo } = useFlowHistory() + const canvas = useCanvasStore() + const file = useFileStore() + const toasts = useToastStore() + + const launchQueue = /** @type {any} */ (window).launchQueue + launchQueue?.setConsumer(async (/** @type {{ files?: any[] }} */ params) => { + const [handle] = params.files ?? [] + if (!handle) return + const opened = await handle.getFile() + openText(await opened.text(), opened.name, handle) + }) + + watch( + [isLoading, () => route.query], + ([loading, query]) => { + const shared = ['text', 'title', 'url'].map((key) => String(query[key] ?? '')) + if (loading || !shared.some(Boolean)) return + + const [text, title, url] = shared + router.replace({ name: ROUTE.FLOW, query: {} }) + const document = readSharedText({ text, title, url }) + if (!document) { + toasts.push('What was shared is not a diagram isketch can read.', { tone: 'danger' }) + return + } + + canvas.forgetViewport() + file.forget() + replace.mutate(document, { + onSuccess: () => + toasts.push('Opened what was shared. Undo brings yours back.', { + action: { label: 'Undo', run: undo }, + }), + }) + }, + { immediate: true }, + ) +} diff --git a/src/domain/__tests__/sharedText.spec.js b/src/domain/__tests__/sharedText.spec.js new file mode 100644 index 0000000..5b6dd8b --- /dev/null +++ b/src/domain/__tests__/sharedText.spec.js @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' + +import { readSharedText } from '../sharedText.js' + +describe('readSharedText', () => { + it('reads .flow text', () => { + const document = readSharedText({ + text: 'title: Pay\npay = process "Pay"\nship = process "Ship"\npay -> ship', + }) + expect(document.title).toBe('Pay') + expect(document.nodes.map((node) => node.id)).toEqual(['pay', 'ship']) + expect(document.edges).toHaveLength(1) + }) + + it('reads a Mermaid flowchart, named after the shared title', () => { + const document = readSharedText({ + text: 'flowchart TD\n A[Start] --> B[Ship]', + title: 'Release', + }) + expect(document.title).toBe('Release') + expect(document.nodes).toHaveLength(2) + }) + + it('finds the diagram when an app puts it in the url field', () => { + expect(readSharedText({ url: 'a = process "A"' })?.nodes).toHaveLength(1) + }) + + it('refuses text that is not a diagram', () => { + expect(readSharedText({ text: 'Look at this!' })).toBeNull() + expect(readSharedText({})).toBeNull() + }) +}) diff --git a/src/domain/sharedText.js b/src/domain/sharedText.js new file mode 100644 index 0000000..1486402 --- /dev/null +++ b/src/domain/sharedText.js @@ -0,0 +1,22 @@ +import { parseFlow } from './flowText.js' +import { fromMermaid } from './mermaid.js' + +/** + * Text another app shared to isketch, as a diagram: `.flow` text first, then + * a Mermaid flowchart. Null when it is neither. + * + * @param {{ text?: string, title?: string, url?: string }} shared + * @returns {import('./types.js').FlowDocument | null} + */ +export function readSharedText({ text = '', title = '', url = '' }) { + // Apps disagree about where the words go; some put everything in `url`. + const body = [text, url].filter(Boolean).join('\n').trim() + if (!body) return null + + const asFlow = parseFlow(body).document + const found = asFlow?.nodes.length ? asFlow : fromMermaid(body).document + if (!found?.nodes.length) return null + + const named = title.trim() + return named && !/^title:/m.test(body) ? { ...found, title: named } : found +} diff --git a/src/main.js b/src/main.js index ab9e269..5b649c0 100644 --- a/src/main.js +++ b/src/main.js @@ -23,3 +23,12 @@ createApp(App) .use(router) .use(VueQueryPlugin, { queryClientConfig }) .mount('#app') + +// Offline and installable: the build writes /sw.js; development has none. +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => { + // No worker, no offline: the app itself works the same. + }) + }) +} diff --git a/src/pwa/__tests__/serviceWorker.spec.js b/src/pwa/__tests__/serviceWorker.spec.js new file mode 100644 index 0000000..91d8159 --- /dev/null +++ b/src/pwa/__tests__/serviceWorker.spec.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { PUBLIC_SHELL, serviceWorkerSource, shellFiles } from '../serviceWorker.js' + +describe('shellFiles', () => { + it('lists the build and the public shell once each, without maps or itself', () => { + const files = shellFiles([ + 'index.html', + 'assets/index-abc.js', + 'assets/index-abc.js.map', + 'sw.js', + ]) + expect(files).toContain('/index.html') + expect(files).toContain('/assets/index-abc.js') + expect(files).not.toContain('/assets/index-abc.js.map') + expect(files).not.toContain('/sw.js') + for (const file of PUBLIC_SHELL) expect(files).toContain(file) + expect(new Set(files).size).toBe(files.length) + }) +}) + +describe('serviceWorkerSource', () => { + const source = serviceWorkerSource('v1', ['/index.html', '/assets/a.js']) + + it('names its cache after the build, and caches the shell on install', () => { + expect(source).toContain('const CACHE = "isketch-v1"') + expect(source).toContain('const SHELL = ["/index.html","/assets/a.js"]') + expect(source).toContain('cache.addAll(SHELL)') + }) + + it('is valid JavaScript', () => { + expect(() => new Function(source)).not.toThrow() + }) +}) diff --git a/src/pwa/serviceWorker.js b/src/pwa/serviceWorker.js new file mode 100644 index 0000000..c46450a --- /dev/null +++ b/src/pwa/serviceWorker.js @@ -0,0 +1,96 @@ +/** + * The source of /sw.js, written by the build with the files of that build, so + * the app opens with no connection. Hand rolled: the app shell is a short list, + * and a generator would be a large dependency for it. + * + * - A page is fetched from the network first, so a deploy shows at once; with + * no network, the cached page, or the app for any of its routes. + * - Everything else of our own comes from the cache first: built files have + * hashed names, so a cached one is never stale. + * - Other origins (the hosted-links server, analytics) are left to the network. + * + * @param {string} version changes whenever the files do + * @param {string[]} files the shell, as paths from the root + * @returns {string} + */ +export function serviceWorkerSource(version, files) { + return `// isketch service worker, build ${version}. Written by the build; do not edit. +const CACHE = ${JSON.stringify(`isketch-${version}`)} +const SHELL = ${JSON.stringify(files)} +const APP = '/index.html' +// A module import sends Origin and the install did not; the names are hashed, so Vary is moot. +const MATCH = { ignoreVary: true } + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))) + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), + ) +}) + +self.addEventListener('fetch', (event) => { + const request = event.request + const url = new URL(request.url) + if (request.method !== 'GET' || url.origin !== self.location.origin) return + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request).catch(async () => { + const cache = await caches.open(CACHE) + const page = url.pathname === '/' ? '/landing.html' : url.pathname + return ( + (await cache.match(page, MATCH)) ?? (await cache.match(APP, MATCH)) ?? Response.error() + ) + }), + ) + return + } + + event.respondWith( + caches.match(request, MATCH).then( + (cached) => + cached ?? + fetch(request).then((response) => { + if (response.ok) { + const copy = response.clone() + caches.open(CACHE).then((cache) => cache.put(request, copy)) + } + return response + }), + ), + ) +}) +` +} + +/** Files from public/ that the app shell needs, beside the build's own. */ +export const PUBLIC_SHELL = Object.freeze([ + '/landing.html', + '/manifest.webmanifest', + '/favicon.svg', + '/favicon.ico', + '/icon-192.png', + '/icon-512.png', + '/apple-touch-icon.png', + '/analytics.js', +]) + +/** + * The shell for a build: its entry pages and hashed files, then the public + * ones, without maps or duplicates. + * @param {string[]} built file names from the bundle, relative to the root + * @returns {string[]} + */ +export function shellFiles(built) { + const own = built + .filter((name) => !name.endsWith('.map') && name !== 'sw.js') + .map((name) => `/${name}`) + return [...new Set([...own, ...PUBLIC_SHELL])].sort() +} diff --git a/src/views/FlowView.vue b/src/views/FlowView.vue index a232489..29f6b98 100644 --- a/src/views/FlowView.vue +++ b/src/views/FlowView.vue @@ -21,6 +21,7 @@ import { useViewKeys } from '@/composables/useViewKeys.js' import { useHelpDialog } from '@/composables/useHelpDialog.js' import { PHONE, useMediaQuery } from '@/composables/useMediaQuery.js' import { useOpenSharedLink } from '@/composables/useShareLink.js' +import { useLaunch } from '@/composables/useLaunch.js' import { useFlowQuery } from '@/composables/useFlowQuery.js' import { useCanvasStore } from '@/stores/canvas.js' import { useToastStore } from '@/stores/toasts.js' @@ -35,6 +36,7 @@ const isComparing = ref(false) const isExporting = ref(false) const isSharing = ref(false) useOpenSharedLink() +useLaunch() const { copyBrief } = useCopyBrief() const { document } = useFlowQuery() // Bound at the shell: a dialog that is not mounted cannot listen for its own key. diff --git a/vite.config.js b/vite.config.js index c7b3162..b55f38f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath, URL } from 'node:url' @@ -6,6 +7,7 @@ import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' import { analyticsScript } from './src/api/analytics.js' +import { serviceWorkerSource, shellFiles } from './src/pwa/serviceWorker.js' /** Where links point when the head is built; %SITE_URL% in index.html. */ const SITE_URL = (process.env.VITE_SITE_URL ?? 'https://isketch.online').replace(/\/+$/, '') @@ -76,6 +78,22 @@ export default defineConfig({ }) }, }, + { + // /sw.js, listing this build's files, so the app opens offline. + name: 'service-worker', + apply: 'build', + // After Vite has added the HTML pages to the bundle. + enforce: 'post', + generateBundle(_options, bundle) { + const files = shellFiles(Object.keys(bundle)) + const version = createHash('sha256').update(files.join('\n')).digest('hex').slice(0, 12) + this.emitFile({ + type: 'asset', + fileName: 'sw.js', + source: serviceWorkerSource(version, files), + }) + }, + }, ], resolve: {