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
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ src/lib/draft.ts What the edit form edits, and the pure parsing around it
src/lib/text.ts String helpers every surface shares
src/lib/url.ts Small URL helpers
src/lib/amazon-book.ts Amazon product HTML → ISBN → Goodreads URL. Pure.
src/lib/suggest.ts `suggestShortcuts`: visited pages → ranked keyword suggestions. Pure.
src/lib/history.ts The only `chrome.history` caller: optional-permission check, request, read
src/lib/install.ts The onInstalled branch: starter pick, rule sync, welcome tab
src/background.ts MV3 service worker: listener registration, rule sync, omnibox
src/content/ Isolated-world content scripts. `amazon-goodreads.ts` is IIFE-bundled.
Expand Down Expand Up @@ -374,6 +376,15 @@ the obvious edit reverses it.
starter pick is written first. It comes apart from "a pick is live" for a format 1 profile
arriving from Settings, or an install whose write failed: those have every shipped shortcut on and
no pick on record, so `initialPicks` opens the starter set ticked rather than an empty screen.
- **`history` stays in `optional_permissions`.** Adding a permission to `permissions` makes Chrome
disable the extension on update for every existing user until they accept the new warning.
`tests/manifest.test.ts` guards it. `src/lib/history.ts` is the only file that requests or reads
it, and it treats "not granted" as no suggestions, never as an error. The request must run inside
the click handler: Chrome refuses it otherwise.
- **`suggest.ts` stays pure, like `resolve.ts`.** No `chrome.*` and no DOM, so the ranking is tested
with a plain array. A suggestion becomes a shortcut only through the ordinary New shortcut form,
so every keyword still passes `validateAlias`. Only `settings.dismissedSuggestions` persists; the
visits never do.

## Verify by executing, not by reading

Expand All @@ -388,7 +399,7 @@ stubs `globalThis.chrome` and exercises the **production** path. Note that only

## The test suite

20 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and
22 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and
the size is a decision rather than an accident. The question a test has to answer is: **if this
vanished and the code broke, would a user notice?**

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
BunnyLol reads the ISBN off the product details (locally) and opens
`goodreads.com/book/isbn/…` for that same book. Pages without an ISBN are
left alone.
- **Suggest shortcuts**, on the Shortcuts page and the welcome screen. With
your permission, BunnyLol reads the last 90 days of your history locally
and offers a keyword for the sites you keep going back to that no shortcut
reaches yet. Add opens the New shortcut form already filled in, × dismisses
a site for good, and the toolbar popup lists up to three while its box is
empty. `history` is an optional permission, asked for only when you click
the button, so updating does not prompt or disable anything. The visits
are never stored or sent; only the dismissed sites are kept, and exported.

## [1.1.0] - 2026-09-02

Expand Down
31 changes: 27 additions & 4 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Privacy Policy

Last updated: 2026-09-07
Last updated: 2026-09-24

## Summary

Expand All @@ -12,7 +12,10 @@ telemetry, no remote code and no network requests of its own.
BunnyLol keeps one JSON value under the key `bunnylol.state.v1` (`STORAGE_KEY`
in `src/lib/types.ts`) in `chrome.storage.local` on your device (`saveState`
in `src/lib/storage.ts`). It holds your custom shortcuts, any shipped
shortcuts you turned off or edited, and your settings. Nothing is written to
shortcuts you turned off or edited, and your settings. The settings include
`dismissedSuggestions`, the hostnames of any shortcut suggestions you
dismissed (see below), and nothing else about the sites you visit. Like the
rest of the state, that list is in the exported file. Nothing is written to
`chrome.storage.sync`. Uninstalling the extension deletes it.

The extension also caches its rule-registration status under
Expand All @@ -39,8 +42,8 @@ do not match are left untouched and go to the search engine as normal.

## What the extension can see

BunnyLol does not request the `tabs` permission and has no access to your
browsing history. Three places open a tab, and all of them use only
BunnyLol does not request the `tabs` permission. It has no access to your
browsing history unless you opt in, as described below. Three places open a tab, and all of them use only
`chrome.tabs.create` and `chrome.tabs.update`, which do not require that
permission: the toolbar popup (`src/popup/popup.ts`), the omnibox keyword
(`src/background.ts`), and the welcome tab shown once on install
Expand All @@ -53,6 +56,26 @@ button navigates your tab to Goodreads. The ISBN never leaves the browser
except as the path of that navigation you started. Pages without an ISBN are
untouched. No other site is injected into.

### Shortcut suggestions (opt-in)

`history` is an optional permission (`optional_permissions` in
`public/manifest.json`). BunnyLol asks for it only when you click **Suggest
shortcuts**, on the Shortcuts page or the welcome screen, and Chrome shows
its own prompt. Until you accept, the extension cannot read your history.

With the permission granted, the options page and the toolbar popup call
`chrome.history.search` for the last 90 days when they open (`loadSuggestions`
in `src/lib/history.ts`). The visits are ranked locally (`suggestShortcuts` in
`src/lib/suggest.ts`) into a few sites you might want a keyword for. Sites a
shortcut already reaches, search engines, `localhost`, IP addresses and hosts
you dismissed are skipped. The visits are never stored and never sent
anywhere; they are read again the next time either page opens. The only
thing kept is the hostname of a suggestion you dismiss with ×.

To revoke the permission, open `chrome://extensions`, click **Details** on
BunnyLol and remove it under **Permissions**, or remove it from Chrome's
extension permission settings. Suggestions stop, and nothing else changes.

## Third parties

None. A shortcut may navigate you to a third-party site such as GitHub or
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ the same book on Goodreads from its ISBN. No ISBN, no button.

![View on Goodreads button on an Amazon book page](docs/images/amazon-goodreads-button.png)

**Suggest shortcuts** (opt-in) looks at the sites you visit most and offers a keyword for each one
no shortcut reaches yet: a card on the Shortcuts page, and up to three rows in the toolbar popup
while its box is empty. Add opens the New shortcut form already filled in, and × dismisses a site
for good. It needs Chrome's optional `history` permission, requested only when you click the
button. Your history is read locally, on demand, and never stored or sent.

![Suggested shortcuts card on the Shortcuts page](docs/images/suggestions.png)

The toolbar popup gives you autocomplete when you do not want to leave the current page:

<p align="center">
Expand Down
Binary file added docs/images/suggestions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"type": "module"
},
"permissions": ["storage", "declarativeNetRequest"],
"optional_permissions": ["history"],
"host_permissions": [
"https://www.google.com/*",
"https://www.bing.com/*",
Expand Down
62 changes: 62 additions & 0 deletions src/lib/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* The `chrome.history` side of shortcut suggestions: the one file that asks for
* the permission and reads the visits. `history` is an OPTIONAL permission, so
* nothing here assumes it is granted, and a profile that never opted in reads
* as no suggestions rather than an error.
*
* The visits are read on demand and never stored. What persists is only
* `settings.dismissedSuggestions`, the hosts a user said no to.
*/

import { suggestShortcuts } from './suggest';
import type { Suggestion } from './suggest';
import type { Command, Settings } from './types';

const HISTORY = { permissions: ['history'] };
/** How far back "a site you keep going back to" looks. */
const WINDOW_MS = 90 * 24 * 60 * 60 * 1000;

export async function hasHistoryAccess(): Promise<boolean> {
try {
return await chrome.permissions.contains(HISTORY);
} catch {
return false;
}
}

/** Must run inside the click handler that asked: Chrome refuses it otherwise. */
export async function requestHistoryAccess(): Promise<boolean> {
try {
return await chrome.permissions.request(HISTORY);
} catch {
return false;
}
}

export async function loadSuggestions(
commands: Command[],
settings: Settings,
limit?: number,
): Promise<Suggestion[]> {
if (!(await hasHistoryAccess())) return [];
try {
const pages = await chrome.history.search({
text: '',
startTime: Date.now() - WINDOW_MS,
maxResults: 5000,
});
return suggestShortcuts(
pages.map((page) => ({
url: page.url ?? '',
title: page.title,
visitCount: page.visitCount,
typedCount: page.typedCount,
})),
commands,
settings.dismissedSuggestions,
limit,
);
} catch {
return [];
}
}
15 changes: 15 additions & 0 deletions src/lib/storage/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,24 @@ export function normalizeSettings(raw: unknown): Settings {
googleAccount: normalizeAccount(source.googleAccount),
interceptStopList: normalizeStopList(source.interceptStopList),
dispatchToast: source.dispatchToast === true,
dismissedSuggestions: normalizeHosts(source.dismissedSuggestions),
};
}

/** Enough for years of dismissals; a hand-edited file cannot grow it unbounded. */
const MAX_DISMISSED = 500;

function normalizeHosts(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const hosts = new Set<string>();
for (const entry of raw) {
if (hosts.size >= MAX_DISMISSED) break;
const host = trimmed(entry).toLowerCase();
if (host && !/\s/.test(host)) hosts.add(host);
}
return [...hosts];
}

/**
* The exemption list. Missing means "never configured" and gets the shipped
* default, which is empty: every registered keyword is intercepted until the
Expand Down
154 changes: 154 additions & 0 deletions src/lib/suggest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Shortcut suggestions: the sites a user keeps going back to, minus the ones a
* shortcut already reaches, each with a keyword nothing else answers to.
*
* Pure, like `resolve.ts`: no `chrome.*` and no DOM. The pages come from
* `chrome.history` through `lib/history.ts`, which is the only file that knows
* where they came from, so this ranking is testable with a plain array.
*
* A suggestion only ever becomes a shortcut through the ordinary New shortcut
* form (`prefillFor` → `#new?prefill=`), so nothing here writes, and every
* keyword still meets `validateAlias` on the way in.
*/

import { SEARCH_ENGINES } from './commands';
import { buildKeyMap } from './resolve';
import type { Command } from './types';
import { validateAlias } from './validate';

export interface VisitedPage {
url: string;
title?: string;
visitCount?: number;
typedCount?: number;
}

export interface Suggestion {
alias: string;
/** The site's origin: a shortcut to the home page, never to one deep link. */
url: string;
name: string;
/** Hostname without `www.`: what a dismissal records. */
host: string;
score: number;
}

/** Typing an address is the habit a keyword replaces, so it counts triple. */
const TYPED_WEIGHT = 3;
/** Below this a site was visited, not returned to. */
const MIN_SCORE = 5;

export function suggestShortcuts(
pages: VisitedPage[],
commands: Command[],
dismissed: string[],
limit = 5,
): Suggestion[] {
const skip = new Set(dismissed.map((host) => host.toLowerCase()));
for (const engine of SEARCH_ENGINES) skip.add(bareHost(engine.host));
for (const cmd of commands) {
for (const url of [cmd.url, cmd.searchUrl]) {
const host = hostOf(url ?? '');
if (host) skip.add(host);
}
}

const sites = new Map<string, { score: number; origin: string; title: string; best: number }>();
for (const page of pages) {
let url: URL;
try {
url = new URL(page.url);
} catch {
continue;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') continue;
const host = bareHost(url.hostname);
if (skip.has(host) || !isPublicHost(host)) continue;
const score = (page.visitCount ?? 0) + TYPED_WEIGHT * (page.typedCount ?? 0);
const site = sites.get(host) ?? { score: 0, origin: `${url.origin}/`, title: '', best: -1 };
site.score += score;
// The name comes off the most visited page of the site, which is usually
// the one titled after the site rather than after one document on it.
if (score > site.best) {
site.best = score;
site.title = page.title ?? '';
}
sites.set(host, site);
}

const taken = new Set(buildKeyMap(commands).keys());
const out: Suggestion[] = [];
const ranked = [...sites].filter(([, s]) => s.score >= MIN_SCORE);
ranked.sort((a, b) => b[1].score - a[1].score || a[0].localeCompare(b[0]));
for (const [host, site] of ranked) {
if (out.length >= limit) break;
const alias = pickAlias(host, taken);
if (!alias) continue;
taken.add(alias);
out.push({
alias,
url: site.origin,
name: siteName(site.title, host),
host,
score: site.score,
});
}
return out;
}

/** The `#new?prefill=` text `parsePrefill` reads back: keyword, URL, name. */
export function prefillFor(s: Suggestion): string {
return `${s.alias} ${s.url} ${s.name}`;
}

function hostOf(url: string): string {
try {
return bareHost(new URL(url).hostname);
} catch {
return '';
}
}

function bareHost(host: string): string {
return host.toLowerCase().replace(/^www\./, '');
}

function isPublicHost(host: string): boolean {
if (!host.includes('.') || host.endsWith('.local') || host.endsWith('.localhost')) return false;
// An IPv4 address or a bracketed IPv6 one names a machine, not a site.
return !/^[\d.]+$/.test(host) && !host.startsWith('[');
}

/**
* The label a person would call the site by: `linear.app` → `linear`,
* `mail.proton.me` → `proton`, `bbc.co.uk` → `bbc`. Then the subdomain
* (`docs.google.com` → `docs`), then a prefix, then a numbered one.
*/
function pickAlias(host: string, taken: Set<string>): string {
const main = mainLabel(host);
const labels = host.split('.');
const candidates = [main, labels[0]!, main.slice(0, 2), main.slice(0, 3)];
for (let n = 2; n < 10; n++) candidates.push(`${main}${n}`);
for (const candidate of candidates) {
const check = validateAlias(candidate.replace(/[^a-z0-9-]/g, ''));
if (check.ok && check.alias.length > 1 && !taken.has(check.alias)) return check.alias;
}
return '';
}

function mainLabel(host: string): string {
const labels = host.split('.');
// ponytail: no public-suffix list. Two short trailing labels (co.uk, com.au)
// are read as one suffix; a rarer shape just gets a less obvious keyword.
const suffix =
labels.length > 2 && labels.at(-1)!.length <= 3 && labels.at(-2)!.length <= 3 ? 2 : 1;
return labels[labels.length - suffix - 1] ?? labels[0]!;
}

/** `Linear – Plan and build products` → `Linear`; no title → `Linear` off the host. */
function siteName(title: string, host: string): string {
const lead = title.split(/\s+[|\-–—·:]\s+/)[0]?.trim() ?? '';
if (lead && lead.length <= 40) return lead;
const label = mainLabel(host);
return label.charAt(0).toUpperCase() + label.slice(1);
}
6 changes: 6 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ export interface Settings {
* as "off". Grep landed you here: there is no toast left to find.
*/
dispatchToast: boolean;
/**
* Hosts (no `www.`) the user dismissed from shortcut suggestions, so they are
* never offered again. See `lib/suggest.ts`.
*/
dismissedSuggestions: string[];
}

export type SearchEngineId = 'google' | 'bing' | 'duckduckgo';
Expand Down Expand Up @@ -333,6 +338,7 @@ export const DEFAULT_SETTINGS: Settings = {
googleAccount: 0,
interceptStopList: [...DEFAULT_STOP_LIST],
dispatchToast: false,
dismissedSuggestions: [],
};

export const DEFAULT_OVERRIDES: Overrides = {
Expand Down
Loading
Loading