From 43932ed1ffefded312ba82d71f3d2049f3fe5195 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:22:10 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9E=20Restore=20PDF=20tabs=20after=20e?= =?UTF-8?q?xtension=20reloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the original web or local PDF URL, page position, and tab placement across unpacked extension reloads. Reopen missing viewer tabs, refresh surviving tabs, respect local file access permissions, and cover recovery with regression tests. Co-authored-by: Codex Web --- README.md | 6 + package.json | 2 +- src/background.js | 5 + src/background/pdf-tab-recovery.js | 206 +++++++++++++++++++++++++++++ src/viewer/viewer.js | 9 ++ tests/pdf-tab-recovery.test.mjs | 146 ++++++++++++++++++++ 6 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/background/pdf-tab-recovery.js create mode 100644 tests/pdf-tab-recovery.test.mjs diff --git a/README.md b/README.md index 62dffa2..5a4c8e6 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,9 @@ Page previews load progressively without delaying the first readable pages. The ### ChatGPT PDF attachment handoff Clicking **Summarize with ChatGPT** now passes the already-loaded PDF bytes as an attachment, including for local `file://` and authenticated PDFs. The service worker relays acknowledged chunks in memory; it does not save PDF bytes to extension storage. ChatGPT's upload UI can change: if the attachment is not confirmed, the extension leaves an unsent draft asking for manual attachment rather than submitting a URL-only summary. Full local filesystem paths are not copied into the prompt. + +## Keeping PDF tabs open when reloading the extension + +The viewer remembers the original URL and physical page of each open PDF in extension-local storage. Reloading an unpacked extension from `chrome://extensions` attempts to reopen extension-owned tabs that Chrome removed, or refreshes surviving tabs in place, including PDFs opened from the local filesystem. The recovered document is fetched from its original source; PDF contents are never stored in the recovery registry. Intentionally closed tabs are removed from the registry. The first reload after adding this feature can only restore tabs that were registered by the updated viewer. + +For `file://` PDFs, turn on **Allow access to file URLs** in the extension's Chrome details page. Chrome 118+ forbids extensions from reopening local file URLs without this permission. Files that were moved, deleted, or whose permissions changed cannot be restored. Incognito tabs are not persisted. diff --git a/package.json b/package.json index 814a18e..be4c34d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node scripts/build.mjs", "check": "node scripts/check-extension.mjs", - "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs" + "test": "node --test tests/pdf-tab-recovery.test.mjs tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs" }, "dependencies": { "pdfjs-dist": "6.3.289", diff --git a/src/background.js b/src/background.js index 343bc44..8a517d0 100644 --- a/src/background.js +++ b/src/background.js @@ -133,3 +133,8 @@ chrome.runtime.onConnect.addListener((port) => { transfer.source.postMessage({ type: "start" }); } }); + +// The recovery helper is a classic worker script in both source and built layouts. +if (typeof importScripts === "function") { + importScripts(chrome.runtime.getURL(sourcePrefix + "background/pdf-tab-recovery.js")); +} diff --git a/src/background/pdf-tab-recovery.js b/src/background/pdf-tab-recovery.js new file mode 100644 index 0000000..806a7c5 --- /dev/null +++ b/src/background/pdf-tab-recovery.js @@ -0,0 +1,206 @@ +// Keep PDF tab identities across unpacked-extension reloads, including file:// PDFs. +// Only URLs and reading positions are saved; PDF bytes never enter extension storage. +(() => { + const STORAGE_KEY = "pdf-viewer-open-tabs-v1"; + const VIEWERS = ["src/viewer.html", "viewer.html"].map((path) => chrome.runtime.getURL(path)); + let registryPromise; + let writes = Promise.resolve(); + let recovering = false; + const replacements = new Map(); + + function validSource(value) { + try { + const url = new URL(value); + return ["https:", "http:", "file:"].includes(url.protocol) ? url.href : null; + } catch { + return null; + } + } + + function viewerSource(value) { + try { + const url = new URL(value); + if (!VIEWERS.some((viewer) => url.origin + url.pathname === viewer)) return null; + return validSource(url.searchParams.get("url")); + } catch { + return null; + } + } + + function tabSource(value) { + const explicit = viewerSource(value); + if (explicit) return explicit; + const source = validSource(value); + if (!source) return null; + return /\.pdf$/i.test(new URL(source).pathname) ? source : null; + } + + function sameDocument(a, b) { + try { + const first = new URL(a); + const second = new URL(b); + first.hash = ""; + second.hash = ""; + return first.href === second.href; + } catch { + return false; + } + } + + function resumeUrl(record) { + const source = new URL(record.source); + if (record.mode === "viewer") { + const viewer = VIEWERS[record.viewerIndex === 1 ? 1 : 0]; + const target = new URL(viewer); + target.searchParams.set("url", source.href); + if (record.page > 1) target.hash = "page=" + record.page; + return target.href; + } + if (record.page > 1) source.hash = "page=" + record.page; + return source.href; + } + + async function registry() { + registryPromise ||= chrome.storage.local.get(STORAGE_KEY).then((data) => { + const saved = data[STORAGE_KEY]; + return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : {}; + }).catch(() => ({})); + return registryPromise; + } + + function persist(entries) { + const snapshot = Object.fromEntries(Object.entries(entries).map(([key, value]) => [key, { ...value }])); + writes = writes.catch(() => {}).then(() => chrome.storage.local.set({ [STORAGE_KEY]: snapshot })); + return writes; + } + + async function track(tab, source, page, preferredMode) { + if (!Number.isInteger(tab?.id) || tab.id < 0 || tab.incognito) return; + const safeSource = validSource(source); + if (!safeSource) return; + const entries = await registry(); + const old = entries[tab.id]; + const mode = preferredMode || (viewerSource(tab.url) ? "viewer" : "source"); + const activeViewerIndex = VIEWERS.findIndex((viewer) => tab.url?.startsWith(viewer)); + entries[tab.id] = { + source: safeSource, + mode, + viewerIndex: activeViewerIndex === 1 ? 1 : 0, + page: Number.isSafeInteger(page) && page > 0 ? page : (sameDocument(old?.source, safeSource) ? old.page : 1) || 1, + windowId: tab.windowId, + index: tab.index, + pinned: Boolean(tab.pinned), + active: Boolean(tab.active), + }; + await persist(entries); + } + + async function forget(tabId) { + const entries = await registry(); + if (entries[tabId]) { + delete entries[tabId]; + await persist(entries); + } + } + + // Only the extension's own top-level viewer can register a PDF. This also + // covers MIME-handler PDFs whose browser tab displays the original file URL. + chrome.runtime.onMessage.addListener((message, sender, respond) => { + if (!["pdf-viewer:opened", "pdf-viewer:page"].includes(message?.type)) return; + if (!VIEWERS.some((viewer) => sender.url?.startsWith(viewer))) return; + const tab = sender.tab; + if (!tab || !Number.isInteger(tab.id) || tab.incognito) return; + const source = validSource(message.sourceUrl); + if (!source) return; + const currentViewerSource = viewerSource(tab.url); + if (!sameDocument(currentViewerSource || tab.url, source) && + !VIEWERS.some((viewer) => tab.url?.startsWith(viewer))) return; + void track(tab, source, message.page, message.mimeHandlerActive ? "source" : "viewer") + .then(() => respond({ ok: true })) + .catch(() => respond({ ok: false })); + return true; + }); + + chrome.tabs.onUpdated.addListener((tabId, changes, tab) => { + if (!changes.url || tab?.incognito) return; + const source = tabSource(changes.url); + if (source) { + void track({ ...tab, id: tabId }, source); + } else if (/^(https?:|file:|about:|chrome:)/.test(changes.url) && + !changes.url.startsWith("chrome-error:")) { + void forget(tabId); + } + }); + + chrome.tabs.onRemoved.addListener((tabId) => { + // A reload may destroy extension-owned tabs just as the old worker exits. + // Defer normal close cleanup so an interrupted worker preserves recovery data. + if (!recovering) setTimeout(() => { if (!recovering) void forget(tabId); }, 2000); + }); + + async function fileAccessAllowed() { + if (!chrome.extension?.isAllowedFileSchemeAccess) return false; + try { return await chrome.extension.isAllowedFileSchemeAccess(); } + catch { return false; } + } + + async function recover(onlyMissing = false) { + recovering = true; + try { + const entries = await registry(); + const before = Object.entries(entries) + .filter(([, record]) => validSource(record?.source)) + .sort((a, b) => (a[1].windowId - b[1].windowId) || (a[1].index - b[1].index)); + const existing = new Map((await chrome.tabs.query({})).map((tab) => [tab.id, tab])); + const mayOpenFiles = await fileAccessAllowed(); + + for (const [oldId, record] of before) { + if (new URL(record.source).protocol === "file:" && !mayOpenFiles) continue; + const tab = existing.get(Number(oldId)) || existing.get(replacements.get(oldId)); + const target = resumeUrl(record); + if (tab) { + // Never take over a tab that navigated to a different page. + if (!sameDocument(viewerSource(tab.url) || tab.url, record.source)) { + await forget(oldId); + continue; + } + if (onlyMissing) continue; + try { + if (tab.url === target) await chrome.tabs.reload(tab.id); + else await chrome.tabs.update(tab.id, { url: target }); + } catch { /* Keep existing tab when Chrome refuses a reload. */ } + continue; + } + + // Chrome may have closed an extension-owned tab. Recreate only the + // exact registered document, in its original window and order. + let created; + try { + created = await chrome.tabs.create({ + url: target, windowId: record.windowId, index: record.index, + pinned: record.pinned, active: Boolean(record.active), + }); + } catch { + try { created = await chrome.tabs.create({ url: target, active: Boolean(record.active) }); } + catch { continue; } + } + replacements.set(oldId, created.id); + existing.set(created.id, created); + entries[created.id] = { ...record, windowId: created.windowId, index: created.index }; + delete entries[oldId]; + await persist(entries); + } + } finally { + recovering = false; + } + } + + chrome.runtime.onInstalled.addListener(({ reason }) => { + if (reason === "update") { + // Unpacked extension Reload is an update. A second pass covers tabs + // removed just after the new worker starts without recreating duplicates. + void recover().then(() => new Promise((resolve) => setTimeout(resolve, 1200))) + .then(() => recover(true)).catch((error) => console.warn("PDF tab recovery failed", error)); + } + }); +})(); \ No newline at end of file diff --git a/src/viewer/viewer.js b/src/viewer/viewer.js index 75c09d0..40f0d78 100644 --- a/src/viewer/viewer.js +++ b/src/viewer/viewer.js @@ -231,6 +231,10 @@ function recordCurrentPage() { if (!pdfDocument || !originalUrl) return; try { saveResumedPage(window.sessionStorage, originalUrl, initialViewerHash, currentPage); + void chrome.runtime.sendMessage({ + type: "pdf-viewer:page", sourceUrl: originalUrl.href, + page: currentPage, mimeHandlerActive, + }).catch(() => {}); } catch { // Storage may be unavailable in a restricted viewer context. } @@ -1479,6 +1483,11 @@ async function initialize() { // Fall back to the incoming page link when storage is unavailable. } const requestedPage = resumedPage ?? getInitialPage(window.location, originalUrl); + // Record the original PDF URL before streaming or rendering can fail. + void chrome.runtime.sendMessage({ + type: "pdf-viewer:opened", sourceUrl: originalUrl.href, + page: requestedPage, mimeHandlerActive, + }).catch(() => {}); requestUrl = new URL(originalUrl.href); requestUrl.hash = ""; diff --git a/tests/pdf-tab-recovery.test.mjs b/tests/pdf-tab-recovery.test.mjs new file mode 100644 index 0000000..1f9f943 --- /dev/null +++ b/tests/pdf-tab-recovery.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const worker = await readFile(new URL("../src/background/pdf-tab-recovery.js", import.meta.url), "utf8"); +const VIEWER = "chrome-extension://test-extension/src/viewer.html"; + +function setup({ fileAccess = true } = {}) { + const events = {}; + const openTabs = new Map(); + const stored = {}; + const calls = { create: [], update: [], reload: [] }; + let nextId = 100; + + const chrome = { + runtime: { + getURL: (path) => "chrome-extension://test-extension/" + path, + onInstalled: { addListener(fn) { events.install = fn; } }, + onMessage: { addListener(fn) { events.message = fn; } }, + }, + extension: { async isAllowedFileSchemeAccess() { return fileAccess; } }, + storage: { + local: { + async get(key) { return { [key]: stored[key] }; }, + async set(values) { Object.assign(stored, values); }, + }, + }, + tabs: { + onUpdated: { addListener(fn) { events.updated = fn; } }, + onRemoved: { addListener(fn) { events.removed = fn; } }, + async query() { return [...openTabs.values()]; }, + async create(options) { + calls.create.push(options); + const tab = { + id: nextId++, url: options.url, windowId: options.windowId ?? 1, + index: options.index ?? 0, active: options.active, pinned: options.pinned, + }; + openTabs.set(tab.id, tab); + return tab; + }, + async update(id, changes) { + calls.update.push({ id, ...changes }); + const tab = { ...openTabs.get(id), ...changes }; + openTabs.set(id, tab); + return tab; + }, + async reload(id) { calls.reload.push(id); }, + }, + }; + + vm.runInNewContext(worker, { + chrome, URL, console, + setTimeout: (fn, milliseconds) => setTimeout(fn, milliseconds >= 1200 ? 5 : milliseconds), + }); + + async function opened(tab, source, page, mimeHandlerActive) { + openTabs.set(tab.id, tab); + const result = await new Promise((resolve) => events.message({ + type: "pdf-viewer:opened", sourceUrl: source, page, mimeHandlerActive, + }, { url: VIEWER, tab }, resolve)); + assert.equal(result.ok, true); + } + + const tick = () => new Promise((resolve) => setTimeout(resolve, 35)); + return { events, openTabs, stored, calls, opened, tick }; +} + +test("a local redirected PDF returns in the same window with its physical page", async () => { + const fixture = setup(); + const source = "file:///Users/example/My%20Report.pdf"; + const tab = { + id: 12, windowId: 6, index: 4, active: true, pinned: true, + url: VIEWER + "?url=" + encodeURIComponent(source), + }; + await fixture.opened(tab, source, 19, false); + fixture.openTabs.delete(12); // Chrome closed the extension-owned tab while reloading. + fixture.events.install({ reason: "update" }); + await fixture.tick(); + + assert.equal(fixture.calls.create.length, 1); + const restored = fixture.calls.create[0]; + assert.equal(restored.windowId, 6); + assert.equal(restored.index, 4); + assert.equal(restored.active, true); + assert.equal(restored.pinned, true); + assert.equal(new URL(restored.url).searchParams.get("url"), source); + assert.equal(new URL(restored.url).hash, "#page=19"); + assert.equal(Object.keys(fixture.stored["pdf-viewer-open-tabs-v1"]).length, 1); +}); + +test("MIME-handler local PDFs reopen with file URLs, not ephemeral blob streams", async () => { + const fixture = setup(); + const source = "file:///tmp/local.pdf"; + await fixture.opened({ id: 7, url: source, windowId: 1, index: 0 }, source, 8, true); + fixture.openTabs.delete(7); + fixture.events.install({ reason: "update" }); + await fixture.tick(); + assert.equal(fixture.calls.create.length, 1); + assert.equal(fixture.calls.create[0].url, "file:///tmp/local.pdf#page=8"); +}); + +test("local files cannot be reopened without Chrome file-scheme permission", async () => { + const fixture = setup({ fileAccess: false }); + const source = "file:///tmp/restricted.pdf"; + await fixture.opened({ id: 7, url: source, windowId: 1, index: 0 }, source, 8, true); + fixture.openTabs.delete(7); + fixture.events.install({ reason: "update" }); + await fixture.tick(); + assert.equal(fixture.calls.create.length, 0); + assert.equal(Object.keys(fixture.stored["pdf-viewer-open-tabs-v1"]).length, 1); +}); + +test("web PDFs reload in the existing tab instead of duplicating it", async () => { + const fixture = setup(); + const source = "https://example.org/document.pdf"; + await fixture.opened({ id: 7, url: source, windowId: 1, index: 0 }, source, 8, true); + fixture.events.install({ reason: "update" }); + await fixture.tick(); + assert.equal(fixture.calls.create.length, 0); + assert.deepEqual(fixture.calls.update, [{ id: 7, url: source + "#page=8" }]); +}); + +test("intentionally closed PDFs do not return on a later extension reload", async () => { + const fixture = setup(); + const source = "https://example.org/document.pdf"; + await fixture.opened({ id: 7, url: source, windowId: 1, index: 0 }, source, 8, true); + fixture.openTabs.delete(7); + fixture.events.removed(7); + await fixture.tick(); // Allow the close cleanup to finish before the reload. + fixture.events.install({ reason: "update" }); + await fixture.tick(); + assert.equal(fixture.calls.create.length, 0); +}); + +test("unrelated browser tabs are never overwritten with old PDFs", async () => { + const fixture = setup(); + const source = "https://example.org/document.pdf"; + await fixture.opened({ id: 7, url: source, windowId: 1, index: 0 }, source, 8, true); + fixture.openTabs.set(7, { id: 7, url: "https://example.org/home", windowId: 1, index: 0 }); + fixture.events.install({ reason: "update" }); + await fixture.tick(); + assert.equal(fixture.calls.create.length, 0); + assert.equal(fixture.calls.update.length, 0); + assert.equal(Object.keys(fixture.stored["pdf-viewer-open-tabs-v1"]).length, 0); +});