From 962f114c749b49ae702ea3e23fa84454f36aa3f1 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 1 Nov 2026 13:12:46 -0500 Subject: [PATCH 1/2] Prepare v0.1.3 release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8481890..e35c9d1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@plannotator/app-notes", "description": "A local-first browser extension for annotating elements on any web page.", "private": true, - "version": "0.1.2", + "version": "0.1.3", "license": "SEE LICENSE IN LICENSE", "author": { "name": "Michael Ramos", From d424e73f571ff4873f19a6da77a8286e5af60d6b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 1 Nov 2026 13:18:48 -0500 Subject: [PATCH 2/2] Offer a page reload when the content script is missing Tabs opened before install, or left open across an update, have no content script until they reload. The popup called these pages unavailable and disabled its controls, which looks like a broken extension. Offer a reload instead, except on browser store pages or after a reload did not help. --- docs/RELEASE_CHECKLIST.md | 1 + entrypoints/popup/App.tsx | 54 +++++++++++++++++++------- lib/page-availability.test.ts | 70 ++++++++++++++++++++++++++++++++++ lib/page-availability.ts | 72 +++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 lib/page-availability.test.ts create mode 100644 lib/page-availability.ts diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index efbc066..3861126 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -80,6 +80,7 @@ Test on Chrome Stable, Firefox Stable, Edge Stable, and current Arc. Test Safari - [ ] On a blocked local HTML file in Chrome, choose **Allow local files**, approve Chrome's toggle, return to the file, and confirm App Notes reloads it automatically. - [ ] Confirm local notes default to **This file**; switch to **This folder** and verify sibling-file notes appear while a different folder remains isolated. - [ ] Restricted browser pages fail gracefully without broken controls. +- [ ] On a tab opened before installing App Notes, the popup offers **Reload page**, and annotations work after it. Browser store pages say App Notes isn't available and offer no reload. - [ ] Switch the operating system/browser between light and dark appearance; popup, composer, toast, markers, and notes workspace update automatically and remain legible on both light and dark host pages. - [ ] Keyboard focus order, visible focus, screen-reader labels, and 200% zoom are usable. - [ ] Reduced-motion mode avoids unnecessary motion. diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 90dfe80..880ad6a 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -21,6 +21,10 @@ import { } from '@/lib/storage'; import { openNotesWorkspace } from '@/lib/open-notes-workspace'; import { parseOpenLocalFileSettingsResult } from '@/lib/local-file-access'; +import { + parsePageReloadAttempt, + resolveUnreachablePageAvailability, +} from '@/lib/page-availability'; import type { AnnotationCollectionIdentity } from '@/lib/annotation-scope'; interface AnnotationModeResponse { @@ -31,9 +35,20 @@ type PageAvailability = | 'checking' | 'file-access-blocked' | 'file-reload-required' + | 'page-reload-required' | 'ready' | 'unsupported'; +const PAGE_RELOAD_ATTEMPT_KEY = 'app-notes:page-reload-attempt'; + +function getUnreachableAvailability(tabId: number, url: string): PageAvailability { + return resolveUnreachablePageAvailability( + tabId, + url, + parsePageReloadAttempt(localStorage.getItem(PAGE_RELOAD_ATTEMPT_KEY)), + ); +} + function parseAnnotationModeResponse(value: unknown): AnnotationModeResponse | null { if (typeof value !== 'object' || value === null) return null; const active = Reflect.get(value, 'active'); @@ -96,15 +111,14 @@ function App() { const response = parseAnnotationModeResponse(rawResponse); if (cancelled) return; if (!response) { - setAvailability(isLocalFileUrl(tab.url) ? 'file-reload-required' : 'unsupported'); + setAvailability(getUnreachableAvailability(tab.id, tab.url)); return; } + localStorage.removeItem(PAGE_RELOAD_ATTEMPT_KEY); setActive(response.active); setAvailability('ready'); } catch { - if (!cancelled) { - setAvailability(isLocalFileUrl(tab.url) ? 'file-reload-required' : 'unsupported'); - } + if (!cancelled) setAvailability(getUnreachableAvailability(tab.id, tab.url)); } }; @@ -154,7 +168,7 @@ function App() { } setActive(response.active); } catch { - setAvailability(isLocalFileUrl(currentUrl) ? 'file-reload-required' : 'unsupported'); + setAvailability(getUnreachableAvailability(tabId, currentUrl)); } finally { setIsToggling(false); } @@ -181,14 +195,20 @@ function App() { } }; - const handleReloadLocalFile = async () => { + const handleReloadTab = async () => { if (tabId === null || isToggling) return; setIsToggling(true); try { await browser.tabs.reload(tabId); + if (availability === 'page-reload-required') { + localStorage.setItem( + PAGE_RELOAD_ATTEMPT_KEY, + JSON.stringify({ tabId, url: currentUrl }), + ); + } window.close(); } catch { - setStatus('Reload this file, then open App Notes again.'); + setStatus(`Reload this ${availability === 'file-reload-required' ? 'file' : 'page'}, then open App Notes again.`); setIsToggling(false); } }; @@ -246,6 +266,8 @@ function App() { return 'Chrome needs one approval to open local files.'; case 'file-reload-required': return 'Reload this file to finish enabling App Notes.'; + case 'page-reload-required': + return 'Reload this page to start using App Notes.'; case 'unsupported': return 'App Notes isn’t available on this page.'; case 'ready': @@ -263,13 +285,19 @@ function App() { ? { icon: , label: 'Reload local file', - onClick: handleReloadLocalFile, + onClick: handleReloadTab, } - : { - icon: active ? : , - label: active ? 'Annotations enabled' : 'Enable annotations', - onClick: toggleMode, - }; + : availability === 'page-reload-required' + ? { + icon: , + label: 'Reload page', + onClick: handleReloadTab, + } + : { + icon: active ? : , + label: active ? 'Annotations enabled' : 'Enable annotations', + onClick: toggleMode, + }; return (
diff --git a/lib/page-availability.test.ts b/lib/page-availability.test.ts new file mode 100644 index 0000000..a487544 --- /dev/null +++ b/lib/page-availability.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test'; +import { + parsePageReloadAttempt, + resolveUnreachablePageAvailability, +} from './page-availability'; + +describe('unreachable page availability', () => { + test('offers a reload for a website tab with no content script', () => { + expect(resolveUnreachablePageAvailability(7, 'https://example.com/a', null)) + .toBe('page-reload-required'); + }); + + test('keeps the local-file reload flow', () => { + expect(resolveUnreachablePageAvailability(7, 'file:///tmp/a.html', null)) + .toBe('file-reload-required'); + }); + + test.each([ + 'https://chromewebstore.google.com/detail/app-notes/lkknpieefdjpoancolcioblkpgnhpffl', + 'https://chrome.google.com/webstore/category/extensions', + 'https://microsoftedge.microsoft.com/addons/detail/example', + 'https://addons.mozilla.org/en-US/firefox/', + ])('never offers a reload on a browser store page: %s', (url) => { + expect(resolveUnreachablePageAvailability(7, url, null)).toBe('unsupported'); + }); + + test('offers a reload on other pages of hosts that only block a store path', () => { + expect(resolveUnreachablePageAvailability(7, 'https://chrome.google.com/intl/en/chrome/', null)) + .toBe('page-reload-required'); + expect(resolveUnreachablePageAvailability(7, 'https://microsoftedge.microsoft.com/other', null)) + .toBe('page-reload-required'); + }); + + test('stops offering a reload once that page stayed silent after one', () => { + const url = 'https://blocked-by-policy.example/'; + expect(resolveUnreachablePageAvailability(7, url, { tabId: 7, url })).toBe('unsupported'); + }); + + test('still offers a reload for a different tab or page', () => { + const url = 'https://example.com/a'; + expect(resolveUnreachablePageAvailability(8, url, { tabId: 7, url })) + .toBe('page-reload-required'); + expect(resolveUnreachablePageAvailability(7, 'https://example.com/b', { tabId: 7, url })) + .toBe('page-reload-required'); + }); +}); + +describe('page reload attempt parsing', () => { + test('parses a valid attempt as a fresh DTO', () => { + expect(parsePageReloadAttempt(JSON.stringify({ + tabId: 7, + url: 'https://example.com/', + ignored: true, + }))).toEqual({ tabId: 7, url: 'https://example.com/' }); + }); + + test.each([ + null, + '', + 'not json', + 'null', + '[]', + '{"tabId":-1,"url":"https://example.com/"}', + '{"tabId":1.5,"url":"https://example.com/"}', + '{"tabId":"7","url":"https://example.com/"}', + '{"tabId":7}', + ])('rejects malformed input: %s', (input) => { + expect(parsePageReloadAttempt(input)).toBeNull(); + }); +}); diff --git a/lib/page-availability.ts b/lib/page-availability.ts new file mode 100644 index 0000000..8432e43 --- /dev/null +++ b/lib/page-availability.ts @@ -0,0 +1,72 @@ +import { isLocalFileUrl } from './annotation-scope'; + +/** What the popup offers when a tab's content script does not answer. */ +export type UnreachablePageAvailability = + | 'file-reload-required' + | 'page-reload-required' + | 'unsupported'; + +/** The page the popup last reloaded to start its content script. */ +export interface PageReloadAttempt { + readonly tabId: number; + readonly url: string; +} + +/** Store pages where browsers never run extension content scripts. */ +const BROWSER_STORE_PAGES: ReadonlyArray<{ + readonly hostname: string; + readonly pathPrefix: string; +}> = [ + { hostname: 'chromewebstore.google.com', pathPrefix: '/' }, + { hostname: 'chrome.google.com', pathPrefix: '/webstore' }, + { hostname: 'microsoftedge.microsoft.com', pathPrefix: '/addons' }, + { hostname: 'addons.mozilla.org', pathPrefix: '/' }, +]; + +function isBrowserStorePage(url: string): boolean { + try { + const { hostname, pathname } = new URL(url); + return BROWSER_STORE_PAGES.some((page) => ( + hostname === page.hostname && pathname.startsWith(page.pathPrefix) + )); + } catch { + return false; + } +} + +/** + * Decide what to offer when an annotatable tab has no content script. + * + * Tabs opened before App Notes was installed need one reload. Browsers also + * block extensions on pages no list can fully name, such as sites restricted by + * policy, so a page that stays silent after a reload is reported as unavailable + * instead of offering the same reload forever. + */ +export function resolveUnreachablePageAvailability( + tabId: number, + url: string, + lastAttempt: PageReloadAttempt | null, +): UnreachablePageAvailability { + if (isLocalFileUrl(url)) return 'file-reload-required'; + if (isBrowserStorePage(url)) return 'unsupported'; + if (lastAttempt?.tabId === tabId && lastAttempt.url === url) return 'unsupported'; + return 'page-reload-required'; +} + +/** Parse a persisted reload attempt. */ +export function parsePageReloadAttempt(input: string | null): PageReloadAttempt | null { + if (input === null) return null; + + let value: unknown; + try { + value = JSON.parse(input); + } catch { + return null; + } + + if (typeof value !== 'object' || value === null) return null; + const tabId = Reflect.get(value, 'tabId'); + const url = Reflect.get(value, 'url'); + if (typeof tabId !== 'number' || !Number.isSafeInteger(tabId) || tabId < 0) return null; + return typeof url === 'string' ? { tabId, url } : null; +}