Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 41 additions & 13 deletions entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
Expand Down Expand Up @@ -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));
}
};

Expand Down Expand Up @@ -154,7 +168,7 @@ function App() {
}
setActive(response.active);
} catch {
setAvailability(isLocalFileUrl(currentUrl) ? 'file-reload-required' : 'unsupported');
setAvailability(getUnreachableAvailability(tabId, currentUrl));
} finally {
setIsToggling(false);
}
Expand All @@ -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);
}
};
Expand Down Expand Up @@ -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':
Expand All @@ -263,13 +285,19 @@ function App() {
? {
icon: <RotateCw size={15} />,
label: 'Reload local file',
onClick: handleReloadLocalFile,
onClick: handleReloadTab,
}
: {
icon: active ? <Check size={15} /> : <Power size={15} />,
label: active ? 'Annotations enabled' : 'Enable annotations',
onClick: toggleMode,
};
: availability === 'page-reload-required'
? {
icon: <RotateCw size={15} />,
label: 'Reload page',
onClick: handleReloadTab,
}
: {
icon: active ? <Check size={15} /> : <Power size={15} />,
label: active ? 'Annotations enabled' : 'Enable annotations',
onClick: toggleMode,
};

return (
<main className="p-2" aria-label="App Notes controls">
Expand Down
70 changes: 70 additions & 0 deletions lib/page-availability.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
72 changes: 72 additions & 0 deletions lib/page-availability.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading