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; +} 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",