From 06861020f223f74b7a347c5ca0b6fbb8088de617 Mon Sep 17 00:00:00 2001 From: Ish G Date: Sat, 24 Oct 2026 21:47:14 -0700 Subject: [PATCH] Complete Manifest V3 migration and prepare Chrome Web Store release --- .github/workflows/check.yml | 19 +++ .gitignore | 5 + PRIVACY.md | 29 ++++ README | 65 ++++++++- STORE.md | 68 +++++++++ __tests__/extension.test.js | 189 +++++++++++++++++++++++++ __tests__/helpers.cjs | 112 +++++++++++++++ __tests__/manifest.test.js | 5 - js/background.js | 270 +++++++++++++++++++++--------------- js/options.js | 107 ++++++-------- js/popup.js | 116 ++++++++++------ js/settings.js | 41 ++++++ manifest.json | 4 +- options.html | 65 +++++---- package.json | 8 +- popup.html | 43 +++--- privacy.html | 28 ++++ scripts/check.cjs | 10 ++ scripts/package.cjs | 21 +++ store-assets/popup.jpg | Bin 0 -> 18392 bytes store-assets/settings.jpg | Bin 0 -> 73516 bytes styles.css | 36 +++++ 22 files changed, 955 insertions(+), 286 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 .gitignore create mode 100644 PRIVACY.md create mode 100644 STORE.md create mode 100644 __tests__/extension.test.js create mode 100644 __tests__/helpers.cjs delete mode 100644 __tests__/manifest.test.js create mode 100644 js/settings.js create mode 100644 privacy.html create mode 100644 scripts/check.cjs create mode 100644 scripts/package.cjs create mode 100644 store-assets/popup.jpg create mode 100644 store-assets/settings.jpg create mode 100644 styles.css diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..b49056f --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,19 @@ +name: Check extension +on: + push: + pull_request: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - run: npm run package + - uses: actions/upload-artifact@v7 + with: + name: tab-count-package + path: dist/*.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bd1b1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist/ +output/ +node_modules/ +.playwright-cli/ +.DS_Store diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..8e8a687 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,29 @@ +# Tab Count privacy policy + +Effective October 24, 2026 + +Tab Count is a Chrome extension by developerish that counts and manages open browser tabs. Your tab information stays in your browser. The extension does not transmit user data to the developer or any third party. + +## Information used locally + +Tab Count reads open-tab titles, URLs, window identifiers, and tab state to show tab counts and lists, switch to tabs, and close tabs at your request. It does not read the contents of web pages, passwords, form fields, or your browsing history. + +If you enable duplicate notifications, Tab Count compares the URLs of open tabs. Pending notification actions contain tab identifiers and a URL in Chrome's session storage. This information is removed when the notification is handled or the duplicate tab is closed, and is cleared when the browser session ends or the extension is reloaded, disabled, or updated. It is not a persistent browsing record. Notification text does not include page titles or URLs. + +If you enable automatic closing, Tab Count uses Chrome's last-accessed timestamps to identify unused tabs. It checks hourly and keeps active, pinned, playing, loading, incognito, and browser-internal tabs. It does not maintain a separate browsing-history database. + +## Settings and retention + +Your preferences are saved in Chrome's local extension storage on this device. Tab Count does not use Chrome sync storage. Preferences from older versions may be copied from the extension's local storage when you first open the popup or settings. Uninstalling the extension removes its stored settings and session data. + +## Sharing and network activity + +Tab Count contains no analytics, advertising, tracking, remote scripts, or network requests. It does not sell, share, or transfer user data. It does not load remote favicons. Chrome's own extension updates and any websites you open are governed by their respective privacy policies. + +## Your choices + +Duplicate detection and automatic closing are off by default. You can change these settings at any time or remove Tab Count in Chrome's extension settings. The extension does not request access to incognito browsing by default. If you allow that access in Chrome, counts and the tab list can include those tabs; duplicate detection and automatic closing skip incognito tabs. + +## Contact and updates + +For questions about this policy, [open an issue on GitHub](https://github.com/developerish/tab-count/issues). Do not include private URLs or other sensitive information in a public issue. This policy will be updated if the extension's data practices change. diff --git a/README b/README index a056d46..fa6f651 100644 --- a/README +++ b/README @@ -1,8 +1,65 @@ Tab Count Chrome Extension ========================== -This repository contains the source code for the **Tab Count** Chrome extension. -The extension displays the number of open tabs, lets you manage them, and now -uses Manifest V3 with a service worker background script. +Tab Count shows the number of open tabs in Chrome's toolbar and provides a +keyboard-accessible list for switching to and closing tabs. The badge and list +can each show the current window or all windows. -Chrome Web Store: +Chrome Web Store: + +Development +----------- + +Requires Chrome 121+ and Node 22+ for development. No runtime dependencies or +bundling step. Run `npm run check` for behavioral tests and JavaScript syntax +checks. Run `npm run package` to validate and produce the upload ZIP in `dist/`. +The packaging script requires the `zip` command (available on macOS and Linux). + +To test manually, open chrome://extensions, enable Developer mode, choose +Load unpacked, and select `dist/extension` after packaging. Use a disposable +Chrome profile, especially when testing automatic closing. Reload the extension +after changing its files. See STORE.md for release and reviewer instructions. + +Manifest V3 behavior +-------------------- + +All worker event listeners register synchronously. Chrome may stop the worker +when idle; no feature depends on an in-memory timer or activity-history object. + +- `chrome.alarms` runs the optional unused-tab check hourly. The alarm is checked + on every worker start and removed when the setting is turned off. +- Unused-tab closing uses Chrome's `tabs.Tab.lastAccessed` timestamp (Chrome + 121+) rather than maintaining a separate browsing record. It never closes + active, pinned, playing, loading, incognito, or browser-internal tabs. Unknown + or invalid timestamps are kept. The state is checked again before closing. +- Duplicate detection compares exact HTTP(S) URLs, supports more than two + matches, and skips incognito tabs. Pending actions live in session storage so + they survive worker suspension. Clicking a notification or its first button + switches to the existing tab and closes the unchanged duplicate. Dismissing + it or choosing Keep both keeps both tabs. Buttons depend on OS support; the + notification body is also actionable. +- Settings apply immediately without reloading the extension. Old MV2 settings + migrate when the popup or options page is first opened, preserving newer + preferences. Legacy auto-close is left off until explicitly enabled again. + +Privacy and permissions +----------------------- + +Tab information stays in the browser. No network requests, remote favicons, +analytics, content scripts, or host permissions. `tabs` supplies titles, URLs, +and state for the list and optional management features; `storage` saves local +preferences and session notification actions; `alarms` supports the hourly +check; `notifications` displays opt-in duplicate prompts. Both optional features +are off by default. Auto-close may lose unsaved work in otherwise eligible tabs. + +See PRIVACY.md for the published privacy policy and STORE.md for the store's +single-purpose statement, permission justifications, and release procedure. + +Related contributions +--------------------- + +Duplicate detection and unused-tab closing were originally contributed by +rgluga. lordvorp's PR #16 also identified service-worker lifecycle and legacy +storage migration problems: . +This release addresses those problems in the existing js/background.js layout, +using Chrome's own last-accessed timestamps and adding regression coverage. diff --git a/STORE.md b/STORE.md new file mode 100644 index 0000000..844c3d5 --- /dev/null +++ b/STORE.md @@ -0,0 +1,68 @@ +# Chrome Web Store release + +Update the existing item **cfokcacdaonnckdmopmcgeanhkebeaio** rather than creating a new extension. This retains its identity and upgrade path. + +## Build and upload + +1. Run `npm run package` (Node 22 or later, plus `zip`). Upload `dist/tab-count-3.0.0.zip` using **Package → Upload new package** in the Chrome Web Store developer dashboard. +2. Check that version 3.0.0 is newer than every previously uploaded package. If necessary, increment both `manifest.json` and `package.json`, then regenerate the ZIP. +3. Update the store listing and privacy practices using the accurate, source-backed text below. Replace legacy screenshots with `store-assets/popup.jpg` (640×400) and `store-assets/settings.jpg` (1280×800), captured from the packaged extension in a clean Chrome for Testing profile. Both show disposable tabs. Keep the existing icon and promo tile. +4. Resolve any dashboard violations, account verification, contact-email, two-step verification, or developer/trader declarations. The account owner must supply personal or business identity facts and accept new legal terms. +5. Submit the existing item for review with public distribution and automatic publication after approval. Upload acceptance and review submission do not mean the item is live; verify the dashboard and public listing separately. + +## Listing description + +Tab Count shows how many tabs you have open and gives you a compact list for switching to or closing them. + +- Choose whether the toolbar badge counts the current window or all windows. +- List tabs from the current window or all windows. +- Switch to a tab or close it from the popup. +- Optionally get notified when you open a page that is already open. +- Optionally close tabs unused for 1–30 days, with an hourly check. Active, pinned, playing, loading, incognito, and browser-internal tabs are kept. + +Your tab information stays in your browser. No accounts, analytics, advertising, tracking, remote code, or data transmission. Duplicate detection and automatic closing are off by default. Automatic closing can lose unsaved work in otherwise eligible tabs. + +Requires Chrome 121 or later. + +## Single purpose + +Count and manage open browser tabs, including listing, switching, closing, and optional duplicate and unused-tab management. + +## Permission justifications + +- **tabs:** Read open-tab titles and URLs for the tab list and exact duplicate detection. Read tab state and last-accessed timestamps to protect active, pinned, playing, and loading tabs during opt-in unused-tab closing. No page content or browsing-history access. +- **storage:** Save preferences locally and keep pending duplicate-notification actions in session storage so actions work after the Manifest V3 service worker stops. No sync or remote storage. +- **alarms:** Run the optional unused-tab check hourly using a wakeable Chrome alarm instead of a background timer. +- **notifications:** Show the optional duplicate-tab prompt. No page titles or URLs appear in notification text. Clicking switches to the existing tab and closes the unchanged duplicate; dismissing keeps both. +- **Host permissions:** None. +- **Remote code:** No. All executable code is bundled in the extension. + +## Privacy policy URL + +https://github.com/developerish/tab-count/blob/master/PRIVACY.md + +Verify this URL is publicly accessible after pushing the release. The bundled `privacy.html` is the same policy for offline access from settings. + +## Data-use disclosures + +Disclose **Web history** to cover open-tab URLs/titles processed locally; the extension does not read the Chrome History database. Google requires disclosure of local-only data handling too. Other data categories are not used. No user data is transmitted, sold, or transferred to third parties. Confirm the limited-use statements only after verifying they remain true for the uploaded package. + +## Reviewer test instructions + +1. Load the extension with several disposable tabs in two normal Chrome windows. The badge initially shows the total count; the popup initially lists the current window. +2. Open Settings. Change the popup and badge scope. Both changes apply without reloading the extension. Switch windows and move a tab between them to verify the current-window count. +3. Use keyboard navigation to switch to a tab and close a disposable tab. Titles containing HTML markup must appear as literal text. +4. Enable duplicate notifications and open the same HTTP(S) URL twice. Clicking the notification or its first button switches to the existing tab and closes the duplicate. Dismiss it or choose Keep both to retain both. Three or more matching tabs are supported. OS notification settings can suppress visual prompts. +5. Close or navigate one tab before acting on a duplicate prompt: the other tab must be retained. Stop the service worker before clicking a still-pending prompt: the action must still work. +6. Enable unused-tab closing and inspect the `tabJanitor` hourly alarm. Disable it and confirm the alarm is removed. Run `npm test` for controlled stale-tab, navigation, and worker-restart cases without risking real work. + +## Release notes: 3.0.0 + +Completed the Manifest V3 migration with reliable service-worker events, hourly alarms, safe duplicate actions, validated settings, keyboard-accessible tab controls, safe text rendering, and a local-only privacy policy. Uses Chrome's built-in tab last-accessed times instead of maintaining an activity-history database. Protects active, pinned, playing, loading, incognito, and browser-internal tabs from automatic closing. + +## Release verification + +- `npm run package`: 13 behavioral/package tests and syntax checks pass; upload archive contains only extension assets. +- Chrome for Testing 149: package loads without extension errors; current/all-window badge and list, switching across windows, closing a disposable tab, immediate preference changes, persisted settings, and invalid-day validation checked in the actual extension. +- Settings layout checked at 320 pixels, screenshots captured at store sizes, and extension-page console showed no messages. This is a desktop Chrome extension; Chrome on phones does not load it. +- Worker restart, pending notification actions, stale-tab safeguards, and legacy migration have deterministic regression coverage. OS notification presentation and action buttons vary by platform and remain subject to the user's notification settings. diff --git a/__tests__/extension.test.js b/__tests__/extension.test.js new file mode 100644 index 0000000..043781e --- /dev/null +++ b/__tests__/extension.test.js @@ -0,0 +1,189 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { readFileSync, existsSync } = require("node:fs"); +const { resolve } = require("node:path"); +const { createWorker, createSettings } = require("./helpers.cjs"); +const manifest = require("../manifest.json"); +const DAY = 86400000; +const tab = (id, overrides = {}) => ({ + id, windowId: 1, active: false, pinned: false, audible: false, incognito: false, + url: "https://example.org/", status: "complete", lastAccessed: Date.now() - 10 * DAY, + ...overrides +}); + +test("MV3 package references exist and only necessary permissions are requested", () => { + assert.equal(manifest.manifest_version, 3); + assert.equal(manifest.version, require("../package.json").version); + assert.equal(manifest.minimum_chrome_version, "121"); + assert.deepEqual([...manifest.permissions].sort(), ["alarms", "notifications", "storage", "tabs"]); + assert.equal(manifest.host_permissions, undefined); + for (const path of [manifest.background.service_worker, manifest.action.default_popup, manifest.options_page, ...Object.values(manifest.icons)]) { + assert.ok(existsSync(resolve(__dirname, "..", path)), path); + } + for (const file of ["popup.html", "options.html", "privacy.html"]) { + const html = readFileSync(resolve(__dirname, "..", file), "utf8"); + assert.doesNotMatch(html, /]*\bsrc=)[^>]*>/i); + for (const [, path] of html.matchAll(/(?:src|href)="([^":]+)"/g)) { + assert.ok(existsSync(resolve(__dirname, "..", path)), `${file}: ${path}`); + } + } + const popup = readFileSync(resolve(__dirname, "../js/popup.js"), "utf8"); + assert.doesNotMatch(popup, /innerHTML|outerHTML|insertAdjacentHTML/); + assert.doesNotMatch(popup, /favIconUrl/); // Do not contact favicon servers with private URLs. +}); + +test("settings normalize legacy booleans and reject invalid auto-close thresholds", async () => { + for (const days of [0, -1, 31, 1.5, "", "no", null]) { + const settings = createSettings({ tabJanitorDays: days, tabJanitor: "false" }); + const actual = await settings.call("getSettings()"); + assert.equal(actual.tabJanitorDays, 5); + assert.equal(actual.tabJanitor, false); + } + const settings = createSettings({ tabJanitorDays: "30", tabDedupe: "true" }); + assert.equal((await settings.call("getSettings()")).tabJanitorDays, 30); + assert.equal((await settings.call("getSettings()")).tabDedupe, true); +}); + +test("legacy migration preserves newer preferences and does not re-enable old auto-close", async () => { + const settings = createSettings({ popupDisplayOption: "currentWindow", tabDedupe: false }, { + popupDisplayOption: "allWindows", badgeDisplayOption: "currentWindow", + tabDedupe: "true", tabJanitor: "true", tabJanitorDays: "12" + }); + await settings.call("migrateLegacySettings()"); + assert.equal(settings.local.popupDisplayOption, "currentWindow"); + assert.equal(settings.local.tabDedupe, false); + assert.equal(settings.local.badgeDisplayOption, "currentWindow"); + assert.equal(settings.local.tabJanitor, false); + assert.equal(settings.local.tabJanitorDays, 12); + settings.local.badgeDisplayOption = "allWindows"; + await settings.call("migrateLegacySettings()"); + assert.equal(settings.local.badgeDisplayOption, "allWindows"); +}); + +test("listeners register before async initialization; default badge counts every window", async () => { + const worker = createWorker({ tabs: [tab(1), tab(2, { windowId: 2 }), tab(3)] }); + for (const events of [worker.chrome.tabs.onUpdated, worker.chrome.tabs.onRemoved, worker.chrome.windows.onFocusChanged, + worker.chrome.alarms.onAlarm, worker.chrome.notifications.onClicked, worker.chrome.notifications.onButtonClicked, + worker.chrome.storage.onChanged, worker.chrome.runtime.onStartup]) { + assert.equal(events.listeners.length, 1); + } + await worker.settle(); + assert.equal(worker.state.badge, "3"); + assert.equal(worker.state.title, "3 open tabs"); + assert.deepEqual(worker.state.alarms, {}); + assert.deepEqual(worker.state.errors, []); +}); + +test("current-window badge follows focus and moved tabs", async () => { + const worker = createWorker({ tabs: [tab(1), tab(2), tab(3, { windowId: 2 })], local: { badgeDisplayOption: "currentWindow" } }); + await worker.settle(); + assert.equal(worker.state.badge, "2"); + worker.state.focusedWindow = 2; + await worker.chrome.windows.onFocusChanged.emit(2); + assert.equal(worker.state.badge, "1"); + worker.state.tabs[0].windowId = 2; + await worker.chrome.tabs.onAttached.emit(1, {}); + assert.equal(worker.state.badge, "2"); +}); + +test("alarms respond to setting changes and are recreated after worker restart", async () => { + const local = { tabJanitor: true }; + const alarms = {}; + const worker = createWorker({ local, alarms }); + await worker.settle(); + assert.equal(alarms.tabJanitor.periodInMinutes, 60); + delete alarms.tabJanitor; + const restarted = createWorker({ local, alarms }); + await restarted.settle(); + assert.equal(alarms.tabJanitor.periodInMinutes, 60); + local.tabJanitor = false; + await restarted.chrome.storage.onChanged.emit({ tabJanitor: { newValue: false } }, "local"); + assert.equal(alarms.tabJanitor, undefined); +}); + +test("auto-close only removes eligible old tabs and rechecks current state", async () => { + const worker = createWorker({ local: { tabJanitor: true }, tabs: [ + tab(1), tab(2, { active: true }), tab(3, { pinned: true }), tab(4, { audible: true }), + tab(5, { incognito: true }), tab(6, { url: "chrome://settings/" }), + tab(7, { lastAccessed: Date.now() }), tab(8, { lastAccessed: undefined }), + tab(9, { status: "loading" }), tab(10, { pendingUrl: "https://other.example/" }), tab(11) + ] }); + await worker.settle(); + const originalGet = worker.chrome.tabs.get; + worker.chrome.tabs.get = async id => id === 11 ? { ...await originalGet(id), active: true } : originalGet(id); + await worker.chrome.alarms.onAlarm.emit({ name: "tabJanitor" }); + assert.deepEqual(worker.state.removed, [1]); + assert.deepEqual(worker.state.errors, []); +}); + +test("disabled auto-close ignores a late alarm", async () => { + const worker = createWorker({ tabs: [tab(1)], local: { tabJanitor: false } }); + await worker.settle(); + await worker.chrome.alarms.onAlarm.emit({ name: "tabJanitor" }); + assert.deepEqual(worker.state.removed, []); +}); + +test("duplicate detection is optional and matches exact URLs including wildcards", async () => { + const url = "https://example.org/search?q=*#one"; + const worker = createWorker({ tabs: [tab(1, { url }), tab(2, { url }), tab(3, { url })] }); + await worker.settle(); + await worker.chrome.tabs.onUpdated.emit(3, { url }); + assert.deepEqual(worker.state.notifications, {}); + worker.state.local.tabDedupe = true; + await worker.chrome.tabs.onUpdated.emit(3, { url }); + assert.ok(worker.state.notifications["dedupe-3"]); + assert.equal(worker.state.session["dedupe-3"].oldTabId, 1); + assert.ok(!worker.state.notifications["dedupe-3"].message.includes(url)); +}); + +test("pending duplicate action survives worker restart and notification body clicks", async () => { + const local = { tabDedupe: true }; + const session = {}; + const notifications = {}; + const tabs = [tab(1, { windowId: 2 }), tab(2)]; + const first = createWorker({ tabs, local, session, notifications }); + await first.settle(); + await first.chrome.tabs.onUpdated.emit(2, { url: tabs[1].url }); + const restarted = createWorker({ tabs, local, session, notifications }); + await restarted.settle(); + await restarted.chrome.notifications.onClicked.emit("dedupe-2"); + assert.deepEqual(restarted.state.removed, [2]); + assert.equal(restarted.state.focusedWindow, 2); + assert.deepEqual(session, {}); +}); + +test("late notification close events cannot erase a replacement prompt", async () => { + const worker = createWorker({ local: { tabDedupe: true }, tabs: [tab(1), tab(2)] }); + await worker.settle(); + await worker.chrome.tabs.onUpdated.emit(2, { url: "https://example.org/" }); + await worker.chrome.notifications.onClosed.emit("dedupe-2"); + assert.ok(worker.state.session["dedupe-2"]); + delete worker.state.notifications["dedupe-2"]; + await worker.chrome.notifications.onClosed.emit("dedupe-2"); + assert.deepEqual(worker.state.session, {}); +}); + +test("duplicate actions retain navigated, missing, or pinned tabs; Keep both is safe", async () => { + for (const scenario of ["navigated", "pending", "missing", "pinned", "keep", "disabled"]) { + const worker = createWorker({ local: { tabDedupe: true }, tabs: [tab(1), tab(2)] }); + await worker.settle(); + await worker.chrome.tabs.onUpdated.emit(2, { url: "https://example.org/" }); + if (scenario === "navigated") worker.state.tabs[1].url = "https://different.org/"; + if (scenario === "pending") worker.state.tabs[1].pendingUrl = "https://different.org/"; + if (scenario === "missing") worker.state.tabs.shift(); + if (scenario === "pinned") worker.state.tabs[1].pinned = true; + if (scenario === "disabled") worker.state.local.tabDedupe = false; + await worker.chrome.notifications.onButtonClicked.emit("dedupe-2", scenario === "keep" ? 1 : 0); + assert.deepEqual(worker.state.removed, [], scenario); + assert.deepEqual(worker.state.session, {}, scenario); + } +}); + +test("incognito and internal pages do not generate duplicate prompts", async () => { + for (const overrides of [{ incognito: true }, { url: "chrome://newtab/" }, { pinned: true }]) { + const worker = createWorker({ local: { tabDedupe: true }, tabs: [tab(1, overrides), tab(2, overrides)] }); + await worker.settle(); + await worker.chrome.tabs.onUpdated.emit(2, { url: worker.state.tabs[1].url }); + assert.deepEqual(worker.state.notifications, {}); + } +}); diff --git a/__tests__/helpers.cjs b/__tests__/helpers.cjs new file mode 100644 index 0000000..1f32d44 --- /dev/null +++ b/__tests__/helpers.cjs @@ -0,0 +1,112 @@ +const vm = require("node:vm"); +const { readFileSync } = require("node:fs"); +const { resolve } = require("node:path"); + +function event() { + const listeners = []; + return { + listeners, + addListener(listener) { listeners.push(listener); }, + async emit(...args) { + await Promise.all(listeners.map(listener => listener(...args))); + if (this.afterEmit) await this.afterEmit(); + } + }; +} + +function area(data) { + return { + async get(keys) { + if (keys === null) return structuredClone(data); + if (typeof keys === "string") keys = [keys]; + if (Array.isArray(keys)) return structuredClone(Object.fromEntries(keys.filter(key => key in data).map(key => [key, data[key]]))); + return structuredClone({ ...keys, ...Object.fromEntries(Object.keys(keys).filter(key => key in data).map(key => [key, data[key]])) }); + }, + async set(update) { Object.assign(data, structuredClone(update)); }, + async remove(keys) { for (const key of Array.isArray(keys) ? keys : [keys]) delete data[key]; } + }; +} + +function createWorker({ tabs = [], local = {}, session = {}, alarms = {}, notifications = {} } = {}) { + const state = { tabs: structuredClone(tabs), local, session, alarms, notifications, removed: [], errors: [], focusedWindow: 1 }; + const chrome = { + tabs: { + onCreated: event(), onRemoved: event(), onUpdated: event(), onAttached: event(), onDetached: event(), + async query(query = {}) { + return structuredClone(state.tabs.filter(tab => !(query.currentWindow || query.lastFocusedWindow) || tab.windowId === state.focusedWindow)); + }, + async get(id) { + const tab = state.tabs.find(tab => tab.id === id); + if (!tab) throw Error("No such tab"); + return structuredClone(tab); + }, + async update(id, update) { + const tab = state.tabs.find(tab => tab.id === id); + if (!tab) throw Error("No such tab"); + if (update.active) for (const other of state.tabs) if (other.windowId === tab.windowId) other.active = false; + Object.assign(tab, update); + return structuredClone(tab); + }, + async remove(id) { + if (!state.tabs.some(tab => tab.id === id)) throw Error("No such tab"); + state.removed.push(id); + state.tabs = state.tabs.filter(tab => tab.id !== id); + } + }, + windows: { + onCreated: event(), onRemoved: event(), onFocusChanged: event(), + async getAll() { + return [...new Set(state.tabs.map(tab => tab.windowId))].map(id => ({ id, tabs: structuredClone(state.tabs.filter(tab => tab.windowId === id)) })); + }, + async update(id) { state.focusedWindow = id; } + }, + storage: { local: area(local), session: area(session), onChanged: event() }, + alarms: { + onAlarm: event(), + async get(name) { return alarms[name]; }, + async create(name, alarm) { alarms[name] = { name, ...alarm }; }, + async clear(name) { delete alarms[name]; } + }, + notifications: { + onClicked: event(), onButtonClicked: event(), onClosed: event(), + async create(id, details) { notifications[id] = details; }, + async clear(id) { delete notifications[id]; }, + async getAll() { return { ...notifications }; } + }, + action: { + async setBadgeText({ text }) { state.badge = text; }, + async setTitle({ title }) { state.title = title; } + }, + runtime: { onInstalled: event(), onStartup: event() } + }; + const context = vm.createContext({ + chrome, + console: { warn(...args) { state.errors.push(args.join(" ")); } }, + importScripts(file) { vm.runInContext(readFileSync(resolve(__dirname, "../js", file), "utf8"), context); } + }); + vm.runInContext(readFileSync(resolve(__dirname, "../js/background.js"), "utf8"), context); + for (const api of Object.values(chrome)) { + for (const value of Object.values(api)) { + if (value?.listeners) value.afterEmit = () => vm.runInContext("pendingWork", context); + } + } + return { + chrome, state, context, + async settle() { await vm.runInContext("pendingWork", context); }, + async call(source) { return await vm.runInContext(source, context); } + }; +} + +function createSettings(local = {}, legacy = {}) { + const context = vm.createContext({ + chrome: { storage: { local: area(local) } }, + localStorage: { getItem(key) { return legacy[key] ?? null; } } + }); + vm.runInContext(readFileSync(resolve(__dirname, "../js/settings.js"), "utf8"), context); + return { local, async call(source) { + const result = await vm.runInContext(source, context); + return result === undefined ? result : JSON.parse(JSON.stringify(result)); + } }; +} + +module.exports = { createWorker, createSettings }; diff --git a/__tests__/manifest.test.js b/__tests__/manifest.test.js deleted file mode 100644 index 48153a5..0000000 --- a/__tests__/manifest.test.js +++ /dev/null @@ -1,5 +0,0 @@ -const assert = require('assert'); -const manifest = require('../manifest.json'); - -assert.strictEqual(manifest.manifest_version, 3); -console.log('manifest version is 3'); diff --git a/js/background.js b/js/background.js index b52b7e2..90c54b4 100644 --- a/js/background.js +++ b/js/background.js @@ -1,132 +1,186 @@ "use strict"; -let allWindowsTabCount = 0; -let windowCount = 0; -const tab_activation_history = {}; - -function updateBadgeTitle(count) { - const title = `You have ${count} open tab(s).`; - chrome.action.setTitle({ title }); +importScripts("settings.js"); + +const JANITOR_ALARM = "tabJanitor"; +const DEDUPE_PREFIX = "dedupe-"; +const DAY_MS = 24 * 60 * 60 * 1000; + +// Serialize event work so older badge queries and notification actions cannot +// overwrite newer results. Persistent state lives in Chrome, not this queue. +let pendingWork = Promise.resolve(); +function run(task) { + pendingWork = pendingWork.then(task).catch(error => { + console.warn("Tab Count:", error.message); + }); + return pendingWork; } -async function updateBadgeText() { - const { badgeDisplayOption } = await chrome.storage.local.get('badgeDisplayOption'); - if (badgeDisplayOption === undefined || badgeDisplayOption === 'allWindows') { - chrome.action.setBadgeText({ text: String(allWindowsTabCount) }); - updateBadgeTitle(allWindowsTabCount); - } else { - // Feature disabled but kept for compatibility - getCurrentWindowTabs(updateCurrentWindowBadge); +async function refreshBadge() { + const [windows, settings] = await Promise.all([ + chrome.windows.getAll({ populate: true, windowTypes: ["normal"] }), + getSettings() + ]); + const allCount = windows.reduce((count, win) => count + win.tabs.length, 0); + let count = allCount; + if (settings.badgeDisplayOption === "currentWindow") { + const tabs = await chrome.tabs.query({ lastFocusedWindow: true, windowType: "normal" }); + count = tabs.length; } + await Promise.all([ + chrome.action.setBadgeText({ text: String(count) }), + chrome.action.setTitle({ title: `${count} open ${count === 1 ? "tab" : "tabs"}` }) + ]); } -async function getAllStats() { - const window_list = await chrome.windows.getAll({ populate: true }); - return window_list; -} - -async function displayResults(window_list) { - allWindowsTabCount = 0; - windowCount = 0; - for (const win of window_list) { - allWindowsTabCount += win.tabs.length; +async function syncJanitorAlarm() { + const { tabJanitor } = await getSettings(); + if (!tabJanitor) { + await chrome.alarms.clear(JANITOR_ALARM); + } else if (!(await chrome.alarms.get(JANITOR_ALARM))) { + await chrome.alarms.create(JANITOR_ALARM, { periodInMinutes: 60 }); } - await chrome.storage.local.set({ - windowsCount: window_list.length, - allWindowsTabsCount: allWindowsTabCount - }); - updateBadgeText(); } -const dedupeNotifications = {}; - -function registerTabDedupeHandler() { - chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => { - if (changeInfo.url) { - const tabs = await chrome.tabs.query({ url: changeInfo.url }); - if (tabs.length === 2) { - const oldTab = tabs[0].id === tabId ? tabs[1] : tabs[0]; - const notificationId = `dedupe-${tabId}`; - dedupeNotifications[notificationId] = { - oldTab, - newTabId: tabId - }; - chrome.notifications.create(notificationId, { - type: "basic", - iconUrl: "images/icon128.png", - title: "Duplicate tab detected", - message: "Switch to existing open tab?", - buttons: [{ title: "Switch" }], - priority: 0 - }); - } - } - }); - - chrome.notifications.onButtonClicked.addListener( - async (notificationId, buttonIndex) => { - if (dedupeNotifications[notificationId] && buttonIndex === 0) { - const { oldTab, newTabId } = dedupeNotifications[notificationId]; - await chrome.tabs.update(oldTab.id, { active: true }); - await chrome.windows.update(oldTab.windowId, { focused: true }); - chrome.tabs.remove(newTabId); - } - if (dedupeNotifications[notificationId]) { - delete dedupeNotifications[notificationId]; - chrome.notifications.clear(notificationId); - } - } - ); +function isStale(tab, cutoff) { + return !tab.active && !tab.pinned && !tab.audible && !tab.incognito && + !tab.pendingUrl && tab.status !== "loading" && + /^https?:\/\//.test(tab.url || "") && + Number.isFinite(tab.lastAccessed) && tab.lastAccessed > 0 && + tab.lastAccessed < cutoff; } -function registerTabJanitor(days) { - setInterval(() => { - const now = Date.now(); - const threshold = days * 86400000; // 24h in ms - for (const tabId of Object.keys(tab_activation_history)) { - const ts = tab_activation_history[tabId]; - if (now - ts > threshold) { - chrome.tabs.remove(Number(tabId)); +async function closeStaleTabs() { + const settings = await getSettings(); + if (!settings.tabJanitor) return; + const cutoff = Date.now() - settings.tabJanitorDays * DAY_MS; + const tabs = await chrome.tabs.query({ windowType: "normal" }); + for (const tab of tabs) { + if (!isStale(tab, cutoff)) continue; + // Recheck immediately before removing: a queued tab may have become active. + const latestSettings = await getSettings(); + if (!latestSettings.tabJanitor) return; + try { + const latest = await chrome.tabs.get(tab.id); + if (isStale(latest, Date.now() - latestSettings.tabJanitorDays * DAY_MS)) { + await chrome.tabs.remove(tab.id); } + } catch { + // A tab can disappear while Chrome is returning its details. } - }, 1000 * 60 * 60); + } } -chrome.tabs.onActivated.addListener(activeInfo => { - tab_activation_history[activeInfo.tabId] = Date.now(); -}); - -async function init() { - chrome.tabs.onCreated.addListener(() => { - getAllStats().then(displayResults); - }); - - chrome.tabs.onRemoved.addListener(() => { - getAllStats().then(displayResults); - }); - - chrome.windows.onCreated.addListener(() => { - getAllStats().then(displayResults); - }); +async function clearDuplicate(notificationId) { + await chrome.storage.session.remove(notificationId); + await chrome.notifications.clear(notificationId); +} - chrome.windows.onRemoved.addListener(() => { - getAllStats().then(displayResults); +async function promptForDuplicate(tabId, url) { + const notificationId = `${DEDUPE_PREFIX}${tabId}`; + await clearDuplicate(notificationId); + const settings = await getSettings(); + if (!settings.tabDedupe || !/^https?:\/\//.test(url || "")) return; + const tabs = await chrome.tabs.query({ windowType: "normal" }); + const newTab = tabs.find(tab => tab.id === tabId); + if (!newTab || newTab.url !== url || newTab.incognito || newTab.pinned) return; + const oldTab = tabs.find(tab => tab.id !== tabId && !tab.incognito && tab.url === url); + if (!oldTab) return; + + // Session storage survives worker suspension and never leaves this browser. + await chrome.storage.session.set({ + [notificationId]: { oldTabId: oldTab.id, newTabId: tabId, url } }); + try { + await chrome.notifications.create(notificationId, { + type: "basic", + iconUrl: "images/icon128.png", + title: "Duplicate tab detected", + message: "This page is already open. Click to switch to the existing tab and close the duplicate.", + buttons: [{ title: "Switch to existing" }, { title: "Keep both" }] + }); + } catch (error) { + await chrome.storage.session.remove(notificationId); + throw error; + } +} - getAllStats().then(displayResults); - - const { tabDedupe, tabJanitor, tabJanitorDays } = await chrome.storage.local.get([ - 'tabDedupe', - 'tabJanitor', - 'tabJanitorDays' - ]); - if (tabDedupe) { - registerTabDedupeHandler(); +async function resolveDuplicate(notificationId, switchToExisting) { + if (!notificationId.startsWith(DEDUPE_PREFIX)) return; + const stored = await chrome.storage.session.get(notificationId); + const pending = stored[notificationId]; + await clearDuplicate(notificationId); + if (!switchToExisting || !pending || !(await getSettings()).tabDedupe) return; + try { + const [oldTab, newTab] = await Promise.all([ + chrome.tabs.get(pending.oldTabId), chrome.tabs.get(pending.newTabId) + ]); + // Never close a tab that has navigated since the notification was shown. + if (oldTab.url !== pending.url || newTab.url !== pending.url || + oldTab.pendingUrl || newTab.pendingUrl || newTab.pinned || newTab.incognito) return; + await chrome.tabs.update(oldTab.id, { active: true }); + await chrome.windows.update(oldTab.windowId, { focused: true }); + // Re-read after focusing the other window as that operation is asynchronous. + const latest = await chrome.tabs.get(newTab.id); + if (latest.url === pending.url && !latest.pendingUrl && !latest.pinned) { + await chrome.tabs.remove(newTab.id); + } + } catch { + // If either tab was closed, keep any remaining tab intact. } - if (tabJanitor) { - registerTabJanitor(Number(tabJanitorDays) || 5); +} + +async function clearAllDuplicates() { + const stored = await chrome.storage.session.get(null); + for (const key of Object.keys(stored)) { + if (key.startsWith(DEDUPE_PREFIX)) await clearDuplicate(key); } } -init(); +async function initialize() { + await syncJanitorAlarm(); + await refreshBadge(); +} +// Register every event synchronously, before the first asynchronous read. +chrome.tabs.onCreated.addListener(tab => run(async () => { + await refreshBadge(); + if (tab.url) await promptForDuplicate(tab.id, tab.url); +})); +chrome.tabs.onRemoved.addListener(tabId => run(async () => { + await clearDuplicate(`${DEDUPE_PREFIX}${tabId}`); + await refreshBadge(); +})); +chrome.tabs.onAttached.addListener(() => run(refreshBadge)); +chrome.tabs.onDetached.addListener(() => run(refreshBadge)); +chrome.tabs.onUpdated.addListener((tabId, change) => { + if (change.url) run(() => promptForDuplicate(tabId, change.url)); +}); +chrome.windows.onCreated.addListener(() => run(refreshBadge)); +chrome.windows.onRemoved.addListener(() => run(refreshBadge)); +chrome.windows.onFocusChanged.addListener(() => run(refreshBadge)); +chrome.alarms.onAlarm.addListener(alarm => { + if (alarm.name === JANITOR_ALARM) run(closeStaleTabs); +}); +chrome.notifications.onClicked.addListener(id => run(() => resolveDuplicate(id, true))); +chrome.notifications.onButtonClicked.addListener((id, button) => { + run(() => resolveDuplicate(id, button === 0)); +}); +chrome.notifications.onClosed.addListener(id => { + if (id.startsWith(DEDUPE_PREFIX)) run(async () => { + // A URL change can replace a prompt with the same ID before onClosed runs. + const notifications = await chrome.notifications.getAll(); + if (!notifications[id]) await chrome.storage.session.remove(id); + }); +}); +chrome.storage.onChanged.addListener((changes, area) => { + if (area !== "local") return; + run(async () => { + if (changes.tabJanitor || changes.tabJanitorDays) await syncJanitorAlarm(); + if (changes.tabDedupe && !(await getSettings()).tabDedupe) await clearAllDuplicates(); + if (changes.badgeDisplayOption) await refreshBadge(); + }); +}); +chrome.runtime.onInstalled.addListener(() => run(initialize)); +chrome.runtime.onStartup.addListener(() => run(initialize)); +run(initialize); diff --git a/js/options.js b/js/options.js index ad78035..a3f641d 100644 --- a/js/options.js +++ b/js/options.js @@ -1,75 +1,52 @@ "use strict"; -async function save_options(type, value) { - console.log('type: ' + type + ', value: ' + value); - if (type === 'popupCount') { - await chrome.storage.local.set({ popupDisplayOption: value }); - } else if (type === 'tabDedupe') { - await chrome.storage.local.set({ tabDedupe: value }); - } else if (type === 'tabJanitor') { - await chrome.storage.local.set({ tabJanitor: value }); - } else if (type === 'tabJanitorDays') { - await chrome.storage.local.set({ tabJanitorDays: value }); - } else { - await chrome.storage.local.set({ badgeDisplayOption: value }); - chrome.runtime.reload(); - } - const status = document.getElementById('status'); - status.innerHTML = 'Selection Saved...'; - setTimeout(() => { - status.innerHTML = ''; - }, 750); +function showStatus(message) { + document.getElementById("status").textContent = message; } -async function restore_options() { - const data = await chrome.storage.local.get([ - 'popupDisplayOption', - 'tabDedupe', - 'tabJanitor', - 'tabJanitorDays', - 'windowsCount', - 'allWindowsTabsCount' - ]); - - const selection = data.popupDisplayOption; - const radios = document.popupOptionsForm.tabCountRadios; - if (!selection) { - document.getElementById('defaultPopupSelection').checked = true; - } - for (const radio of radios) { - if (radio.value === selection) { - radio.checked = true; - } +async function saveSetting(key, value) { + try { + await chrome.storage.local.set({ [key]: value }); + showStatus("Settings saved."); + } catch { + showStatus("Couldn't save settings. Please try again."); } - - document.getElementById('tabDedupe').checked = Boolean(data.tabDedupe); - document.getElementById('tabJanitor').checked = Boolean(data.tabJanitor); - document.getElementById('tabJanitorDays').value = data.tabJanitorDays || 5; - - document.getElementById('windowsCount').innerHTML = data.windowsCount || 0; - document.getElementById('tabsCount').innerHTML = data.allWindowsTabsCount || 0; } -document.addEventListener('DOMContentLoaded', restore_options); - -const radios = document.popupOptionsForm.tabCountRadios; -for (const radio of radios) { - radio.addEventListener('click', () => save_options('popupCount', radio.value)); +async function refreshStats() { + const windows = await chrome.windows.getAll({ populate: true, windowTypes: ["normal"] }); + document.getElementById("windowsCount").textContent = windows.length; + document.getElementById("tabsCount").textContent = windows.reduce((total, win) => total + win.tabs.length, 0); } -document.getElementById('tabDedupe').addEventListener('click', evt => { - save_options('tabDedupe', evt.target.checked); -}); - -document.getElementById('tabJanitor').addEventListener('click', evt => { - save_options('tabJanitor', evt.target.checked); -}); - -document.getElementById('tabJanitorDays').oninput = () => { - save_options('tabJanitorDays', document.getElementById('tabJanitorDays').valueAsNumber); -}; - -document.getElementById('refreshButton').addEventListener('click', () => { - location.reload(); +document.addEventListener("DOMContentLoaded", async () => { + try { + await migrateLegacySettings(); + const settings = await getSettings(); + for (const radio of document.querySelectorAll('input[type="radio"]')) { + radio.checked = radio.value === settings[radio.name]; + radio.addEventListener("change", () => saveSetting(radio.name, radio.value)); + } + for (const key of ["tabDedupe", "tabJanitor"]) { + const input = document.getElementById(key); + input.checked = settings[key]; + input.addEventListener("change", () => saveSetting(key, input.checked)); + } + const days = document.getElementById("tabJanitorDays"); + days.value = settings.tabJanitorDays; + days.addEventListener("change", () => { + if (!days.validity.valid || !Number.isInteger(days.valueAsNumber)) { + days.reportValidity(); + showStatus("Choose a whole number of days between 1 and 30. Your previous setting is unchanged."); + return; + } + saveSetting("tabJanitorDays", days.valueAsNumber); + }); + document.getElementById("refreshButton").addEventListener("click", () => { + refreshStats().catch(() => showStatus("Couldn't refresh tab counts.")); + }); + await refreshStats(); + } catch { + showStatus("Couldn't load settings. Reload this page to try again."); + } }); - diff --git a/js/popup.js b/js/popup.js index ddc6048..f6fdae6 100644 --- a/js/popup.js +++ b/js/popup.js @@ -1,57 +1,81 @@ "use strict"; -async function getCurrentWindowTabCount() { - const tabs = await chrome.tabs.query({ currentWindow: true }); - const lenText = `Number of tabs on this window: ${tabs.length}`; - document.getElementById('windowTabs').innerHTML = lenText; +let rendering = Promise.resolve(); +function refresh() { + rendering = rendering.then(renderTabs).catch(() => { + document.getElementById("status").textContent = "Couldn't update the tab list. Reopen the popup to try again."; + }); + return rendering; } -async function getCurrentWindowTabs() { - return await chrome.tabs.query({ currentWindow: true }); -} - -async function getAllTabs() { - return await chrome.tabs.query({}); -} +async function renderTabs() { + const settings = await getSettings(); + const currentTabs = await chrome.tabs.query({ currentWindow: true }); + const tabs = settings.popupDisplayOption === "allWindows" + ? await chrome.tabs.query({ windowType: "normal" }) : currentTabs; + document.getElementById("windowTabs").textContent = `${currentTabs.length} ${currentTabs.length === 1 ? "tab" : "tabs"} in this window`; + document.getElementById("listScope").textContent = settings.popupDisplayOption === "allWindows" + ? `${tabs.length} tabs across all windows` : "Current window"; + const list = document.getElementById("tabsList"); + list.replaceChildren(); + document.getElementById("status").textContent = tabs.length ? "" : "No open tabs."; -async function displayResults(tabs) { - await getCurrentWindowTabCount(); - const table = document.getElementById('tabsTable'); for (const tab of tabs) { - const row = table.insertRow(-1); - const cell1 = row.insertCell(0); - const cell2 = row.insertCell(1); - const cell3 = row.insertCell(2); - - if (tab.favIconUrl) { - cell1.innerHTML = ``; - } - cell2.innerHTML = "X"; - cell3.innerHTML = `${tab.title}`; - - cell2.addEventListener('click', () => closeTab(tab.id)); - cell3.addEventListener('click', () => openTab(tab.id, tab.windowId)); + const row = document.createElement("li"); + const title = tab.title || tab.url || "Untitled tab"; + const open = document.createElement("button"); + open.type = "button"; + open.className = "tab-title"; + open.textContent = title; + open.title = tab.url || title; + open.addEventListener("click", async () => { + try { + await chrome.tabs.update(tab.id, { active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + window.close(); + } catch { + await refresh(); + } + }); + const close = document.createElement("button"); + close.type = "button"; + close.className = "close-tab"; + close.textContent = "×"; + close.setAttribute("aria-label", `Close ${title}`); + close.title = `Close ${title}`; + close.addEventListener("click", async () => { + close.disabled = true; + try { + await chrome.tabs.remove(tab.id); + } catch { + document.getElementById("status").textContent = "That tab is no longer available."; + } + await refresh(); + }); + row.append(open, close); + list.append(row); } } -function openTab(tabID, windowID) { - chrome.windows.update(windowID, { focused: true }); - chrome.tabs.update(tabID, { active: true }); -} - -function closeTab(tabID) { - chrome.tabs.remove(tabID); - window.location.reload(); -} - -(async () => { - const { popupDisplayOption } = await chrome.storage.local.get('popupDisplayOption'); - if (popupDisplayOption === undefined || popupDisplayOption === 'currentWindow') { - const tabs = await getCurrentWindowTabs(); - displayResults(tabs); - } else { - const tabs = await getAllTabs(); - displayResults(tabs); +document.addEventListener("DOMContentLoaded", async () => { + document.getElementById("optionsButton").addEventListener("click", () => { + chrome.runtime.openOptionsPage(); + }); + try { + await migrateLegacySettings(); + await refresh(); + } catch { + document.getElementById("status").textContent = "Couldn't load settings. Reopen the popup to try again."; } -})(); +}); +chrome.tabs.onCreated.addListener(refresh); +chrome.tabs.onRemoved.addListener(refresh); +chrome.tabs.onUpdated.addListener((_id, change) => { + if (change.title || change.url) refresh(); +}); +chrome.tabs.onAttached.addListener(refresh); +chrome.tabs.onDetached.addListener(refresh); +chrome.storage.onChanged.addListener((_changes, area) => { + if (area === "local") refresh(); +}); diff --git a/js/settings.js b/js/settings.js new file mode 100644 index 0000000..325ec51 --- /dev/null +++ b/js/settings.js @@ -0,0 +1,41 @@ +"use strict"; + +const SETTINGS_DEFAULTS = Object.freeze({ + popupDisplayOption: "currentWindow", + badgeDisplayOption: "allWindows", + tabDedupe: false, + tabJanitor: false, + tabJanitorDays: 5 +}); + +function normalizeSettings(data) { + const days = Number(data.tabJanitorDays); + return { + popupDisplayOption: data.popupDisplayOption === "allWindows" ? "allWindows" : "currentWindow", + badgeDisplayOption: data.badgeDisplayOption === "currentWindow" ? "currentWindow" : "allWindows", + tabDedupe: data.tabDedupe === true || data.tabDedupe === "true", + tabJanitor: data.tabJanitor === true || data.tabJanitor === "true", + tabJanitorDays: Number.isInteger(days) && days >= 1 && days <= 30 ? days : 5 + }; +} + +async function getSettings() { + return normalizeSettings(await chrome.storage.local.get(SETTINGS_DEFAULTS)); +} + +// Only extension pages can read the old MV2 localStorage. Preserve newer values +// in chrome.storage.local and never resurrect an old enabled auto-close setting. +async function migrateLegacySettings() { + const stored = await chrome.storage.local.get(null); + if (stored.settingsMigrated) return; + const legacy = {}; + for (const key of Object.keys(SETTINGS_DEFAULTS)) { + const value = localStorage.getItem(key); + if (stored[key] === undefined && value !== null) legacy[key] = value; + } + if (legacy.tabJanitor !== undefined) legacy.tabJanitor = false; + const normalized = normalizeSettings(legacy); + const update = { settingsMigrated: true }; + for (const key of Object.keys(legacy)) update[key] = normalized[key]; + await chrome.storage.local.set(update); +} diff --git a/manifest.json b/manifest.json index 930856c..82dbbd8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,8 @@ { "manifest_version": 3, "name": "Tab Count", - "version": "2.8", + "version": "3.0.0", + "minimum_chrome_version": "121", "description": "Displays the count and list of open tabs from current or all windows (configurable) and allows you to select and/or close them.", "options_page": "options.html", "background": { @@ -24,6 +25,7 @@ "permissions": [ "tabs", "storage", + "alarms", "notifications" ] } diff --git a/options.html b/options.html index c5c9503..cef5160 100644 --- a/options.html +++ b/options.html @@ -1,32 +1,43 @@ - - + - TabCount Options - + + + Tab Count settings + + + - - -

Popup window

-
- - -
- -
-
-
-
-

Current Stats

- Total number of windows:
- Total number of tabs:
- - + +
+

Tab Count settings

+
+ Popup tab list + + +
+
+ Toolbar badge count + + +
+
+ Optional tab management + +

Click the notification to switch to the existing tab and close the duplicate. Dismissing it keeps both tabs.

+ + +

Checks hourly. Active, pinned, playing, loading, incognito, and browser-internal tabs are kept. Unsaved work in other tabs could be lost. Closed tabs can usually be reopened from Chrome's History menu.

+
+

+
+

Current counts

+

— windows · — tabs

+ +
+ +
- - - diff --git a/package.json b/package.json index fe46934..ddddada 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "name": "tab-count", - "version": "1.0.0", + "version": "3.0.0", "description": "Tab Count Chrome Extension", - "main": "index.js", + "private": true, "scripts": { - "test": "node __tests__/manifest.test.js" + "test": "node --test __tests__/*.test.js", + "check": "npm test && node scripts/check.cjs", + "package": "npm run check && node scripts/package.cjs" }, "keywords": [], "author": "", diff --git a/popup.html b/popup.html index 4df5fbe..1906d8a 100644 --- a/popup.html +++ b/popup.html @@ -1,31 +1,20 @@ - - + - - + + + Tab Count + + + - - -

-
+ +
+

Tab Count

+ +
+

Loading tabs…

+
    +

    - - \ No newline at end of file + diff --git a/privacy.html b/privacy.html new file mode 100644 index 0000000..a22a73c --- /dev/null +++ b/privacy.html @@ -0,0 +1,28 @@ + + + + + + Tab Count privacy policy + + + +
    +

    Tab Count privacy policy

    +

    Effective October 24, 2026

    +

    Tab Count is a Chrome extension by developerish that counts and manages open browser tabs. Your tab information stays in your browser. The extension does not transmit user data to the developer or any third party.

    +

    Information used locally

    +

    Tab Count reads open-tab titles, URLs, window identifiers, and tab state to show tab counts and lists, switch to tabs, and close tabs at your request. It does not read the contents of web pages, passwords, form fields, or your browsing history.

    +

    If you enable duplicate notifications, Tab Count compares the URLs of open tabs. Pending notification actions contain tab identifiers and a URL in Chrome's session storage. This information is removed when the notification is handled or the duplicate tab is closed, and is cleared when the browser session ends or the extension is reloaded, disabled, or updated. It is not a persistent browsing record. Notification text does not include page titles or URLs.

    +

    If you enable automatic closing, Tab Count uses Chrome's last-accessed timestamps to identify unused tabs. It checks hourly and keeps active, pinned, playing, loading, incognito, and browser-internal tabs. It does not maintain a separate browsing-history database.

    +

    Settings and retention

    +

    Your preferences are saved in Chrome's local extension storage on this device. Tab Count does not use Chrome sync storage. Preferences from older versions may be copied from the extension's local storage when you first open the popup or settings. Uninstalling the extension removes its stored settings and session data.

    +

    Sharing and network activity

    +

    Tab Count contains no analytics, advertising, tracking, remote scripts, or network requests. It does not sell, share, or transfer user data. It does not load remote favicons. Chrome's own extension updates and any websites you open are governed by their respective privacy policies.

    +

    Your choices

    +

    Duplicate detection and automatic closing are off by default. You can change these settings at any time or remove Tab Count in Chrome's extension settings. The extension does not request access to incognito browsing by default. If you allow that access in Chrome, counts and the tab list can include those tabs; duplicate detection and automatic closing skip incognito tabs.

    +

    Contact and updates

    +

    For questions about this policy, open an issue on GitHub. Do not include private URLs or other sensitive information in a public issue. This policy will be updated if the extension's data practices change.

    +
    + + diff --git a/scripts/check.cjs b/scripts/check.cjs new file mode 100644 index 0000000..5679384 --- /dev/null +++ b/scripts/check.cjs @@ -0,0 +1,10 @@ +const { readdirSync } = require("node:fs"); +const { spawnSync } = require("node:child_process"); + +for (const directory of ["js", "scripts", "__tests__"]) { + for (const file of readdirSync(directory).filter(name => /\.(js|cjs)$/.test(name))) { + const result = spawnSync(process.execPath, ["--check", `${directory}/${file}`], { stdio: "inherit" }); + if (result.status !== 0) process.exit(result.status || 1); + } +} +console.log("JavaScript syntax checks passed."); diff --git a/scripts/package.cjs b/scripts/package.cjs new file mode 100644 index 0000000..89a750d --- /dev/null +++ b/scripts/package.cjs @@ -0,0 +1,21 @@ +const { mkdirSync, readFileSync, rmSync, copyFileSync, cpSync } = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const { resolve } = require("node:path"); +const { version } = require("../manifest.json"); + +const root = resolve(__dirname, ".."); +const stage = resolve(root, "dist", "extension"); +const archive = resolve(root, "dist", `tab-count-${version}.zip`); +const files = ["manifest.json", "popup.html", "options.html", "privacy.html", "styles.css"]; + +// Explicit allowlist: no repository history, tests, store docs, or local files. +rmSync(stage, { recursive: true, force: true }); +mkdirSync(stage, { recursive: true }); +for (const file of files) copyFileSync(resolve(root, file), resolve(stage, file)); +for (const directory of ["js", "images"]) cpSync(resolve(root, directory), resolve(stage, directory), { recursive: true }); +rmSync(archive, { force: true }); +const zip = spawnSync("zip", ["-q", "-r", archive, ...files, "js", "images"], { cwd: stage, stdio: "inherit" }); +if (zip.status !== 0) process.exit(zip.status || 1); +const { createHash } = require("node:crypto"); +console.log(`Package: ${archive}`); +console.log(`SHA-256: ${createHash("sha256").update(readFileSync(archive)).digest("hex")}`); diff --git a/store-assets/popup.jpg b/store-assets/popup.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0e29fdc07634139ddee4cd253819e32da27dc650 GIT binary patch literal 18392 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 literal 0 HcmV?d00001 diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..7a6324e --- /dev/null +++ b/styles.css @@ -0,0 +1,36 @@ +:root { color-scheme: light dark; font-family: system-ui, sans-serif; font-size: 14px; } +body { margin: 0; font-size: 1rem; color: #202124; background: #fff; } +button, input { font: inherit; } +button { cursor: pointer; padding: 6px 10px; border: 1px solid #bdc1c6; border-radius: 5px; color: inherit; background: transparent; } +button:hover { background: #f1f3f4; } +:focus-visible { outline: 2px solid #1a73e8; outline-offset: 2px; } +a { color: #1558b0; } +h1 { font-size: 18px; margin: 0; } +h2 { font-size: 16px; } +.popup { width: 360px; max-width: 100vw; box-sizing: border-box; padding: 12px; } +header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +header h1 { font-size: 15px; } +#listScope, .help { color: #5f6368; font-size: 13px; } +#tabsList { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow-y: auto; } +#tabsList li { display: flex; gap: 6px; align-items: center; border-bottom: 1px solid #e8eaed; padding: 3px; } +.tab-title { border: 0; text-align: left; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 8px 4px; } +.close-tab { color: #a32019; border: 0; font-size: 22px; padding: 2px 8px; flex-shrink: 0; } +#status { min-height: 1.4em; font-size: 13px; } +.popup #status:empty { display: none; } +.options main, .privacy main { max-width: 640px; margin: 32px auto; padding: 0 20px; line-height: 1.6; } +fieldset { margin: 20px 0; padding: 12px 16px; border: 1px solid #dadce0; border-radius: 6px; } +legend { font-weight: 600; padding: 0 4px; } +label { display: block; margin: 7px 0; } +input[type="radio"], input[type="checkbox"] { margin-right: 5px; } +input[type="number"] { width: 54px; padding: 4px; } +.days, .help { margin-left: 25px; } +.help { margin-top: 3px; } +footer { border-top: 1px solid #dadce0; margin-top: 24px; padding-top: 8px; font-size: 13px; } +@media (prefers-color-scheme: dark) { + body { color: #e8eaed; background: #202124; } + button:hover { background: #303134; } + #listScope, .help { color: #bdc1c6; } + a { color: #8ab4f8; } + .close-tab { color: #f28b82; } + #tabsList li, fieldset, footer { border-color: #5f6368; } +}