initial commit

This commit is contained in:
Hunter Haugen 2026-04-06 12:46:20 -07:00
commit 6c32ef2323
Signed by: hunner
GPG key ID: EF99694AA599DDAD
23 changed files with 1551 additions and 0 deletions

50
README.md Normal file
View file

@ -0,0 +1,50 @@
# Firefox Focus Mode
Firefox Focus Mode is a Firefox WebExtension prototype inspired by the iOS and Android Firefox Focus app. It narrows the browsing model down to a single surviving tab, a custom home page with four bookmark tiles, a quick control popup, and a one-tap session wipe.
## Included
- Collapses extra tabs and windows back into one active tab
- Replaces the Firefox new-tab page with a Focus-style home page
- Sets Firefox's homepage/new-window page and new-tab page to the Focus-style home page
- Stores and shows up to four bookmarks
- Clears browsing data and returns to the home page
- Exposes back, forward, refresh, home, trash, settings, and URL entry from the toolbar popup
- TODO: Blocks common tracker URLs by default, with optional ad, analytics, and social blocking
## Suggested profile setup
Use a secondary Firefox profile for this extension so the single-tab and session-clearing behavior only affects your Focus-style profile.
## Launcher script
A companion launcher lives at `./firefox-focus`.
- Running `firefox-focus` launches Firefox with a dedicated profile at `~/.mozilla/firefox/focus-mode.profile`.
- By default, `firefox-focus` launches `firefox-devedition`, not stock Firefox.
- Running `firefox-focus example.com` or `firefox-focus "search terms"` opens that target in the Focus profile.
- Running `firefox-focus --kiosk example.com` starts the same profile in Firefox kiosk mode.
- The launcher writes a packaged add-on file into the profile at `extensions/firefox-focus-mode@hunner.dev.xpi`, writes a `user.js` with privacy-focused defaults, and writes `chrome/userChrome.css` to hide the bookmarks bar and tab strip in that dedicated profile.
By default `firefox-focus` opens a regular Firefox window for that dedicated profile. That is expected: the extension can replace the new-tab page and add its own controls, but it cannot remove Firefox's native chrome on its own. Use `firefox-focus --kiosk ...` if you want the stricter fullscreen shell.
If you move this repo later, update `FIREFOX_FOCUS_EXTENSION_DIR` or edit the script so the profile still points at the right checkout.
Depending on your Firefox build, automatic loading of the local `.xpi` may still be limited by Mozilla's add-on signing rules. If that happens, the profile styling changes from `firefox-focus` will still apply, but the extension itself may still need temporary loading or signing.
On standard release Firefox builds, this is the expected limitation. The most reliable persistent-install paths are:
- use a signed `.xpi`
- use a Firefox channel intended for extension development, such as Developer Edition or Nightly
## Important Firefox limits
- Extensions cannot force all browsing into Firefox private windows. This prototype clears browsing data on browser startup and via the trash action, which gets close to the intended behavior without true permanent private mode.
- Extensions cannot remove Firefox's built-in tab strip, address bar, or standard browser buttons. For a stricter kiosk-like shell, pair this extension with a dedicated profile and optional `userChrome.css` tweaks.
- Firefox does not let extensions manage other installed extensions with a whitelist, so that part of the spec is intentionally left out.
## Loading the extension for manual testing
1. Open `about:debugging#/runtime/this-firefox`.
2. Choose `Load Temporary Add-on`.
3. Select `manifest.json`.

479
background.js Normal file
View file

@ -0,0 +1,479 @@
const HOME_URL = browser.runtime.getURL("home.html");
const OPTIONS_URL = browser.runtime.getURL("options.html");
const DEFAULT_SETTINGS = {
bookmarks: [
{ title: "Mozilla", url: "https://www.mozilla.org/" },
{ title: "DuckDuckGo", url: "https://duckduckgo.com/" },
{ title: "Wikipedia", url: "https://www.wikipedia.org/" },
{ title: "Proton", url: "https://proton.me/" }
],
blocking: {
trackers: true,
ads: false,
analytics: false,
social: false
}
};
const BLOCKLISTS = {
trackers: [
"doubleclick.net",
"googlesyndication.com",
"googletagmanager.com",
"googleadservices.com",
"taboola.com",
"outbrain.com",
"scorecardresearch.com",
"zedo.com",
"criteo.com"
],
ads: [
"ads.",
"adservice.",
"adserver.",
"/ads/",
"amazon-adsystem.com",
"adnxs.com",
"rubiconproject.com",
"openx.net"
],
analytics: [
"google-analytics.com",
"analytics.",
"segment.com",
"mixpanel.com",
"hotjar.com",
"fullstory.com",
"amplitude.com",
"plausible.io"
],
social: [
"facebook.net",
"facebook.com/plugins",
"platform.twitter.com",
"connect.facebook.net",
"platform.linkedin.com",
"assets.pinterest.com",
"redditstatic.com"
]
};
let isCollapsingTabs = false;
let currentSettings = cloneDefaultSettings();
const pendingCollapseTimers = new Map();
const pendingCollapseStartedAt = new Map();
const NON_MEANINGFUL_COLLAPSE_GRACE_MS = 800;
function cloneDefaultSettings() {
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
}
function normalizeUrl(rawUrl) {
if (!rawUrl) {
return "";
}
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl)) {
return rawUrl;
}
if (rawUrl.includes(" ") && !rawUrl.includes(".")) {
return `https://duckduckgo.com/?q=${encodeURIComponent(rawUrl)}`;
}
return `https://${rawUrl}`;
}
function sanitizeBookmarks(bookmarks) {
const fallback = cloneDefaultSettings().bookmarks;
const sanitized = Array.isArray(bookmarks) ? bookmarks.slice(0, 4).map((bookmark, index) => {
const safeFallback = fallback[index] || { title: "", url: "" };
return {
title: String(bookmark?.title || safeFallback.title).trim(),
url: String(bookmark?.url || safeFallback.url).trim()
};
}) : [];
while (sanitized.length < 4) {
sanitized.push(fallback[sanitized.length]);
}
return sanitized;
}
async function loadSettings() {
const stored = await browser.storage.local.get("settings");
const defaults = cloneDefaultSettings();
const settings = {
bookmarks: sanitizeBookmarks(stored.settings?.bookmarks || defaults.bookmarks),
blocking: {
trackers: stored.settings?.blocking?.trackers ?? defaults.blocking.trackers,
ads: stored.settings?.blocking?.ads ?? defaults.blocking.ads,
analytics: stored.settings?.blocking?.analytics ?? defaults.blocking.analytics,
social: stored.settings?.blocking?.social ?? defaults.blocking.social
}
};
currentSettings = settings;
return settings;
}
async function saveSettings(settings) {
currentSettings = {
bookmarks: sanitizeBookmarks(settings.bookmarks),
blocking: {
trackers: Boolean(settings.blocking?.trackers),
ads: Boolean(settings.blocking?.ads),
analytics: Boolean(settings.blocking?.analytics),
social: Boolean(settings.blocking?.social)
}
};
await browser.storage.local.set({ settings: currentSettings });
return currentSettings;
}
function isMeaningfulUrl(url) {
if (!url) {
return false;
}
return ![
"about:blank",
"about:newtab",
HOME_URL
].includes(url);
}
async function getAllNormalTabs() {
const windows = await browser.windows.getAll({ populate: true, windowTypes: ["normal"] });
return windows
.sort((left, right) => left.id - right.id)
.flatMap((windowInfo) => (windowInfo.tabs || []).map((tab) => ({ ...tab, __window: windowInfo })));
}
async function getKeeperTab(excludedTabId) {
const windows = await browser.windows.getAll({ populate: true, windowTypes: ["normal"] });
const sortedWindows = windows.sort((left, right) => {
if (left.focused && !right.focused) {
return -1;
}
if (!left.focused && right.focused) {
return 1;
}
return left.id - right.id;
});
for (const windowInfo of sortedWindows) {
const activeTab = (windowInfo.tabs || []).find((tab) => tab.active && tab.id !== excludedTabId);
if (activeTab) {
return activeTab;
}
const firstTab = (windowInfo.tabs || []).find((tab) => tab.id !== excludedTabId);
if (firstTab) {
return firstTab;
}
}
return null;
}
async function focusTab(tabId, windowId) {
await browser.tabs.update(tabId, { active: true });
await browser.windows.update(windowId, { focused: true });
}
function clearPendingCollapse(tabId) {
const timerId = pendingCollapseTimers.get(tabId);
if (timerId) {
clearTimeout(timerId);
pendingCollapseTimers.delete(tabId);
}
pendingCollapseStartedAt.delete(tabId);
}
function queueCollapse(tabId, delay = 150) {
const existingTimerId = pendingCollapseTimers.get(tabId);
if (existingTimerId) {
clearTimeout(existingTimerId);
}
if (!pendingCollapseStartedAt.has(tabId)) {
pendingCollapseStartedAt.set(tabId, Date.now());
}
const timerId = setTimeout(() => {
pendingCollapseTimers.delete(tabId);
void collapseExtraTab(tabId);
}, delay);
pendingCollapseTimers.set(tabId, timerId);
}
async function collapseExtraTab(tabId) {
const timerId = pendingCollapseTimers.get(tabId);
if (timerId) {
clearTimeout(timerId);
pendingCollapseTimers.delete(tabId);
}
if (isCollapsingTabs) {
return;
}
const tab = await browser.tabs.get(tabId).catch(() => null);
if (!tab) {
pendingCollapseStartedAt.delete(tabId);
return;
}
if (!isMeaningfulUrl(tab.url) && (tab.pendingUrl || tab.openerTabId)) {
queueCollapse(tabId);
return;
}
if (!isMeaningfulUrl(tab.url)) {
const allTabs = await getAllNormalTabs();
if (allTabs.length <= 1) {
pendingCollapseStartedAt.delete(tabId);
return;
}
const startedAt = pendingCollapseStartedAt.get(tabId) || Date.now();
if (Date.now() - startedAt < NON_MEANINGFUL_COLLAPSE_GRACE_MS) {
queueCollapse(tabId);
return;
}
}
const allTabs = await getAllNormalTabs();
if (allTabs.length <= 1) {
pendingCollapseStartedAt.delete(tabId);
return;
}
const keeper = await getKeeperTab(tab.id);
if (!keeper) {
pendingCollapseStartedAt.delete(tabId);
return;
}
isCollapsingTabs = true;
try {
const nextUrl = isMeaningfulUrl(tab.url) ? tab.url : HOME_URL;
await browser.tabs.update(keeper.id, { url: nextUrl });
await browser.tabs.remove(tab.id).catch(() => {});
await focusTab(keeper.id, keeper.windowId);
if (tab.windowId !== keeper.windowId) {
await browser.windows.remove(tab.windowId).catch(() => {});
}
} finally {
pendingCollapseStartedAt.delete(tabId);
isCollapsingTabs = false;
}
}
async function enforceSingleTab() {
if (isCollapsingTabs) {
return;
}
const allTabs = await getAllNormalTabs();
if (allTabs.length <= 1) {
return;
}
const keeper = await getKeeperTab();
if (!keeper) {
return;
}
isCollapsingTabs = true;
try {
for (const tab of allTabs) {
if (tab.id === keeper.id) {
continue;
}
if (isMeaningfulUrl(tab.url)) {
await browser.tabs.update(keeper.id, { url: tab.url });
}
await browser.tabs.remove(tab.id).catch(() => {});
}
const windows = await browser.windows.getAll({ windowTypes: ["normal"] });
for (const windowInfo of windows) {
if (windowInfo.id !== keeper.windowId) {
await browser.windows.remove(windowInfo.id).catch(() => {});
}
}
await focusTab(keeper.id, keeper.windowId);
} finally {
isCollapsingTabs = false;
}
}
function shouldBlock(details) {
if (details.tabId < 0) {
return false;
}
const url = details.url || "";
let decodedUrl = url;
try {
decodedUrl = decodeURIComponent(url);
} catch (error) {
decodedUrl = url;
}
return Object.entries(currentSettings.blocking).some(([category, enabled]) => {
if (!enabled) {
return false;
}
return BLOCKLISTS[category].some((pattern) => decodedUrl.includes(pattern));
});
}
async function clearSession(options = {}) {
const settings = options.preserveSettings ? await loadSettings() : currentSettings;
const redirectHome = options.redirectHome ?? true;
await browser.browsingData.remove(
{},
{
cache: true,
cookies: true,
downloads: true,
formData: true,
history: true,
indexedDB: true,
localStorage: true,
serviceWorkers: true
}
);
if (options.preserveSettings) {
await browser.storage.local.clear();
await browser.storage.local.set({ settings });
currentSettings = settings;
}
const keeper = await getKeeperTab();
if (keeper && (redirectHome || !isMeaningfulUrl(keeper.url))) {
await browser.tabs.update(keeper.id, { url: HOME_URL, active: true });
await focusTab(keeper.id, keeper.windowId);
} else if (!keeper) {
await browser.tabs.create({ url: HOME_URL });
}
await enforceSingleTab();
}
async function openInKeeperTab(url) {
const normalized = normalizeUrl(url);
const keeper = await getKeeperTab();
if (keeper) {
await browser.tabs.update(keeper.id, { url: normalized, active: true });
await focusTab(keeper.id, keeper.windowId);
} else {
await browser.tabs.create({ url: normalized });
}
await enforceSingleTab();
}
browser.runtime.onInstalled.addListener(async () => {
await saveSettings(await loadSettings());
});
browser.runtime.onStartup.addListener(async () => {
await loadSettings();
await clearSession({ preserveSettings: true, redirectHome: false });
});
browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName === "local" && changes.settings?.newValue) {
currentSettings = changes.settings.newValue;
}
});
browser.tabs.onCreated.addListener((tab) => {
if (tab.id) {
queueCollapse(tab.id);
}
});
browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.url || changeInfo.status === "complete") {
clearPendingCollapse(tabId);
}
if (changeInfo.url) {
void collapseExtraTab(tabId);
} else if (changeInfo.status === "complete") {
queueCollapse(tabId, 0);
}
});
browser.tabs.onRemoved.addListener((tabId) => {
clearPendingCollapse(tabId);
});
browser.windows.onCreated.addListener(() => {
void enforceSingleTab();
});
browser.webNavigation.onCreatedNavigationTarget.addListener((details) => {
if (details.url) {
void openInKeeperTab(details.url);
}
});
browser.webRequest.onBeforeRequest.addListener(
(details) => ({ cancel: shouldBlock(details) }),
{ urls: ["<all_urls>"] },
["blocking"]
);
browser.runtime.onMessage.addListener((message) => {
if (message?.type === "get-settings") {
return loadSettings();
}
if (message?.type === "save-settings") {
return saveSettings(message.settings || currentSettings);
}
if (message?.type === "clear-session") {
return clearSession({ preserveSettings: true });
}
if (message?.type === "open-url") {
return openInKeeperTab(message.url || HOME_URL);
}
if (message?.type === "open-settings") {
return browser.tabs.create({ url: OPTIONS_URL });
}
if (message?.type === "go-home") {
return openInKeeperTab(HOME_URL);
}
return undefined;
});
void loadSettings();

262
firefox-focus Executable file
View file

@ -0,0 +1,262 @@
#!/usr/bin/env bash
set -euo pipefail
script_name="${0##*/}"
firefox_bin="${FIREFOX_BIN:-firefox-devedition}"
profile_dir="${FIREFOX_FOCUS_PROFILE_DIR:-$HOME/.mozilla/firefox/focus-mode.profile}"
extension_dir="${FIREFOX_FOCUS_EXTENSION_DIR:-$HOME/Documents/git/firefox-focus}"
extension_id="firefox-focus-mode@hunner.dev"
user_js_path="$profile_dir/user.js"
user_chrome_path="$profile_dir/chrome/userChrome.css"
extension_xpi="$profile_dir/extensions/$extension_id.xpi"
legacy_extension_link="$profile_dir/extensions/$extension_id"
kiosk_mode=false
detect_firefox_version() {
"$firefox_bin" --version 2>/dev/null || true
}
warn_about_unsigned_install() {
local version_output="$1"
local firefox_name
firefox_name="$(basename "$firefox_bin")"
case "$version_output" in
*"Developer Edition"*|*"Nightly"*|*"ESR"*)
return
;;
esac
case "$firefox_name" in
firefox-devedition|firefox-developer-edition)
return
;;
esac
cat >&2 <<EOF
Note: persistent local add-on install is usually blocked in standard Firefox
release builds like "$version_output". The Focus profile styling will still
apply, but you may still need "Load Temporary Add-on" in about:debugging unless
you use a signed .xpi or a Firefox channel that allows local unsigned installs.
EOF
}
usage() {
cat <<EOF
Usage: $script_name [-k|--kiosk] [url-or-search] [firefox args...]
Launch Firefox with the dedicated Focus profile and the local Firefox Focus
extension linked into that profile.
Environment overrides:
FIREFOX_BIN Firefox binary to execute (defaults to firefox-devedition)
FIREFOX_FOCUS_PROFILE_DIR Dedicated profile directory
FIREFOX_FOCUS_EXTENSION_DIR Firefox Focus extension checkout
EOF
}
urlencode() {
local input="$1"
local output=""
local i char hex
for ((i = 0; i < ${#input}; i++)); do
char="${input:i:1}"
case "$char" in
[a-zA-Z0-9.~_-])
output+="$char"
;;
' ')
output+='%20'
;;
*)
printf -v hex '%%%02X' "'$char"
output+="$hex"
;;
esac
done
printf '%s\n' "$output"
}
looks_like_url_without_schema() {
local target="$1"
[[ ! "$target" =~ [[:space:]] ]] || return 1
[[ "$target" =~ ^localhost([:/?#].*)?$ ]] && return 0
[[ "$target" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}([:/?#].*)?$ ]] && return 0
[[ "$target" =~ ^([[:alnum:]][[:alnum:]-]*\.)+[[:alpha:]]{2,}([:/?#].*)?$ ]]
}
normalize_target() {
local raw_target="$1"
if [[ -z "$raw_target" ]]; then
printf '%s\n' ""
return
fi
if [[ "$raw_target" =~ ^[[:alpha:]][[:alnum:]+.-]*: ]]; then
printf '%s\n' "$raw_target"
elif looks_like_url_without_schema "$raw_target"; then
printf 'https://%s\n' "$raw_target"
else
printf 'https://search.hunner.dev/search?q=%s\n' "$(urlencode "$raw_target")"
fi
}
write_user_js() {
mkdir -p "$profile_dir"
cat >"$user_js_path" <<EOF
user_pref("app.update.auto", false);
user_pref("browser.discovery.enabled", false);
user_pref("browser.newtabpage.activity-stream.feeds.section.topstories", false);
user_pref("browser.newtabpage.activity-stream.feeds.topsites", false);
user_pref("browser.newtabpage.activity-stream.showSponsored", false);
user_pref("browser.newtabpage.activity-stream.showSponsoredTopSites", false);
user_pref("browser.policies.applied", true);
user_pref("browser.sessionstore.resume_from_crash", false);
user_pref("browser.shell.checkDefaultBrowser", false);
user_pref("browser.startup.homepage", "about:newtab");
user_pref("browser.startup.page", 1);
user_pref("browser.toolbars.bookmarks.visibility", "never");
user_pref("browser.tabs.closeWindowWithLastTab", false);
user_pref("browser.tabs.warnOnClose", false);
user_pref("browser.uiCustomization.state", "{\"placements\":{\"widget-overflow-fixed-list\":[],\"unified-extensions-area\":[],\"nav-bar\":[\"back-button\",\"forward-button\",\"stop-reload-button\",\"urlbar-container\",\"firefox-focus-mode_hunner_dev-browser-action\",\"unified-extensions-button\"],\"toolbar-menubar\":[\"menubar-items\"],\"TabsToolbar\":[\"firefox-view-button\",\"tabbrowser-tabs\",\"new-tab-button\",\"alltabs-button\"],\"vertical-tabs\":[],\"PersonalToolbar\":[]},\"seen\":[\"developer-button\",\"screenshot-button\",\"firefox-focus-mode_hunner_dev-browser-action\"],\"dirtyAreaCache\":[\"nav-bar\",\"vertical-tabs\",\"toolbar-menubar\",\"TabsToolbar\",\"PersonalToolbar\",\"unified-extensions-area\"],\"currentVersion\":23,\"newElementCount\":2}");
user_pref("datareporting.healthreport.uploadEnabled", false);
user_pref("extensions.autoDisableScopes", 0);
user_pref("extensions.enabledScopes", 5);
user_pref("extensions.installDistroAddons", true);
user_pref("xpinstall.signatures.required", false);
user_pref("privacy.clearOnShutdown.cache", true);
user_pref("privacy.clearOnShutdown.cookies", true);
user_pref("privacy.clearOnShutdown.downloads", true);
user_pref("privacy.clearOnShutdown.formdata", true);
user_pref("privacy.clearOnShutdown.history", true);
user_pref("privacy.clearOnShutdown.offlineApps", true);
user_pref("privacy.clearOnShutdown.sessions", true);
user_pref("privacy.donottrackheader.enabled", true);
user_pref("privacy.sanitize.sanitizeOnShutdown", true);
user_pref("privacy.sanitize.timeSpan", 0);
user_pref("signon.rememberSignons", false);
user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
EOF
}
write_user_chrome() {
mkdir -p "$(dirname "$user_chrome_path")"
cat >"$user_chrome_path" <<'EOF'
/* Focus profile chrome trimming */
#PersonalToolbar,
#TabsToolbar {
visibility: collapse !important;
}
#sidebar-button,
#fxa-toolbar-menu-button,
#downloads-button {
display: none !important;
}
#nav-bar {
border-top-width: 0 !important;
}
EOF
}
package_extension() {
if [[ ! -f "$extension_dir/manifest.json" ]]; then
printf 'Extension manifest not found at %s\n' "$extension_dir/manifest.json" >&2
exit 1
fi
mkdir -p "$profile_dir/extensions"
rm -f "$legacy_extension_link"
(
cd "$extension_dir"
zip -qr "$extension_xpi" \
manifest.json \
background.js \
home.html \
home.css \
home.js \
popup.html \
popup.css \
popup.js \
options.html \
options.css \
options.js \
theme.css \
icons
)
}
while [[ $# -gt 0 ]]; do
case "$1" in
-k|--kiosk)
kiosk_mode=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
printf 'Unknown option: %s\n\n' "$1" >&2
usage >&2
exit 1
;;
*)
break
;;
esac
done
target="${1:-}"
if [[ $# -gt 0 ]]; then
shift
fi
if ! command -v "$firefox_bin" >/dev/null 2>&1; then
printf 'Firefox binary not found: %s\n' "$firefox_bin" >&2
exit 1
fi
firefox_version="$(detect_firefox_version)"
write_user_js
write_user_chrome
package_extension
warn_about_unsigned_install "$firefox_version"
firefox_args=(
--new-instance
--profile "$profile_dir"
)
if "$kiosk_mode"; then
firefox_args+=(--kiosk)
fi
target_url="$(normalize_target "$target")"
if [[ -n "$target_url" ]]; then
exec "$firefox_bin" \
"${firefox_args[@]}" \
--new-window "$target_url" \
"$@"
fi
exec "$firefox_bin" \
"${firefox_args[@]}" \
--new-window \
"$@"

124
home.css Normal file
View file

@ -0,0 +1,124 @@
.shell {
max-width: 960px;
margin: 0 auto;
padding: 48px 24px 72px;
}
.hero {
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--accent);
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 0.72rem;
}
h1 {
margin: 0;
font-size: clamp(2.8rem, 7vw, 5rem);
line-height: 0.92;
}
.subtitle {
max-width: 540px;
margin: 16px 0 0;
color: var(--muted);
font-size: 1.05rem;
}
.url-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
margin: 0 0 32px;
}
.url-form input {
width: 100%;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
h2 {
margin: 0;
font-size: 1.1rem;
}
.bookmarks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.bookmark-tile {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 132px;
padding: 18px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.08);
text-decoration: none;
color: var(--text);
transition: transform 160ms ease, border-color 160ms ease;
}
.bookmark-tile:hover,
.bookmark-tile:focus-visible {
transform: translateY(-2px);
border-color: rgba(255, 190, 92, 0.4);
}
.bookmark-title {
margin: 0 0 8px;
font-size: 1rem;
}
.bookmark-url {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
word-break: break-word;
}
.clear-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 36px;
padding: 24px;
border-radius: 28px;
background: rgba(132, 19, 34, 0.32);
border: 1px solid rgba(255, 126, 126, 0.2);
}
.clear-card p {
margin: 8px 0 0;
color: var(--muted);
}
@media (max-width: 720px) {
.shell {
padding-inline: 18px;
}
.url-form {
grid-template-columns: 1fr;
}
.clear-card {
flex-direction: column;
align-items: stretch;
}
}

43
home.html Normal file
View file

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Firefox Focus Mode</title>
<link rel="stylesheet" href="theme.css">
<link rel="stylesheet" href="home.css">
</head>
<body>
<main class="shell">
<section class="hero">
<p class="eyebrow">Privacy-first browsing</p>
<h1>Firefox Focus Mode</h1>
<p class="subtitle">One tab, a fresh session, and just the essentials.</p>
</section>
<form id="url-form" class="url-form">
<label class="sr-only" for="url-input">Enter a URL or search</label>
<input id="url-input" name="url" type="text" placeholder="Enter a URL or search term" autocomplete="off">
<button type="submit">Go</button>
</form>
<section>
<div class="section-heading">
<h2>Bookmarks</h2>
<button id="settings-link" class="ghost-button" type="button">Edit</button>
</div>
<div id="bookmarks" class="bookmarks"></div>
</section>
<section class="clear-card">
<div>
<h2>Erase Session</h2>
<p>Delete browsing data, close extra tabs, and return here.</p>
</div>
<button id="clear-button" class="clear-button" type="button">Trash Everything</button>
</section>
</main>
<script src="home.js"></script>
</body>
</html>

64
home.js Normal file
View file

@ -0,0 +1,64 @@
function normalizeUrl(rawUrl) {
if (!rawUrl) {
return "";
}
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl)) {
return rawUrl;
}
if (rawUrl.includes(" ") && !rawUrl.includes(".")) {
return `https://duckduckgo.com/?q=${encodeURIComponent(rawUrl)}`;
}
return `https://${rawUrl}`;
}
function createBookmarkTile(bookmark) {
const tile = document.createElement("a");
tile.className = "bookmark-tile";
tile.href = normalizeUrl(bookmark.url);
const title = document.createElement("p");
title.className = "bookmark-title";
title.textContent = bookmark.title || "Untitled";
const url = document.createElement("p");
url.className = "bookmark-url";
url.textContent = bookmark.url || "";
tile.append(title, url);
return tile;
}
async function render() {
const settings = await browser.runtime.sendMessage({ type: "get-settings" });
const container = document.getElementById("bookmarks");
container.textContent = "";
settings.bookmarks.forEach((bookmark) => {
container.append(createBookmarkTile(bookmark));
});
}
document.getElementById("url-form").addEventListener("submit", async (event) => {
event.preventDefault();
const input = document.getElementById("url-input");
const url = input.value.trim();
if (!url) {
return;
}
await browser.runtime.sendMessage({ type: "open-url", url });
});
document.getElementById("settings-link").addEventListener("click", async () => {
await browser.runtime.sendMessage({ type: "open-settings" });
});
document.getElementById("clear-button").addEventListener("click", async () => {
await browser.runtime.sendMessage({ type: "clear-session" });
});
void render();

1
icons/focus-16.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

1
icons/focus-32.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

1
icons/focus-48.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

1
icons/focus-96.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

5
icons/old/focus-16.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<rect width="16" height="16" rx="4" fill="#111b28"/>
<path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8A4.8 4.8 0 1 1 8 2.2Z" fill="#f97316"/>
<circle cx="9.8" cy="6.2" r="2.2" fill="#ffbe5c"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

5
icons/old/focus-32.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#111b28"/>
<path d="M16 4.4A11.6 11.6 0 1 0 27.6 16 9.6 9.6 0 1 1 16 4.4Z" fill="#f97316"/>
<circle cx="19.6" cy="12.4" r="4.4" fill="#ffbe5c"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

5
icons/old/focus-48.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<rect width="48" height="48" rx="12" fill="#111b28"/>
<path d="M24 6.6A17.4 17.4 0 1 0 41.4 24 14.4 14.4 0 1 1 24 6.6Z" fill="#f97316"/>
<circle cx="29.4" cy="18.6" r="6.6" fill="#ffbe5c"/>
</svg>

After

Width:  |  Height:  |  Size: 264 B

5
icons/old/focus-96.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<rect width="96" height="96" rx="24" fill="#111b28"/>
<path d="M48 13.2A34.8 34.8 0 1 0 82.8 48 28.8 28.8 0 1 1 48 13.2Z" fill="#f97316"/>
<circle cx="58.8" cy="37.2" r="13.2" fill="#ffbe5c"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

52
manifest.json Normal file
View file

@ -0,0 +1,52 @@
{
"manifest_version": 2,
"name": "Firefox Focus Mode",
"version": "0.1.0",
"description": "A simplified, privacy-focused Firefox experience inspired by Firefox Focus.",
"homepage_url": "https://github.com/hunner/firefox-focus",
"permissions": [
"tabs",
"storage",
"browsingData",
"cookies",
"webRequest",
"webRequestBlocking",
"webNavigation",
"<all_urls>"
],
"background": {
"scripts": [
"background.js"
]
},
"browser_action": {
"default_title": "Firefox Focus Mode",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/focus-16.svg",
"32": "icons/focus-32.svg"
}
},
"options_ui": {
"page": "options.html",
"open_in_tab": true,
"browser_style": false
},
"chrome_settings_overrides": {
"homepage": "home.html"
},
"chrome_url_overrides": {
"newtab": "home.html"
},
"icons": {
"16": "icons/focus-16.svg",
"32": "icons/focus-32.svg",
"48": "icons/focus-48.svg",
"96": "icons/focus-96.svg"
},
"applications": {
"gecko": {
"id": "firefox-focus-mode@hunner.dev"
}
}
}

89
options.css Normal file
View file

@ -0,0 +1,89 @@
.options-shell {
max-width: 860px;
margin: 0 auto;
padding: 44px 24px 80px;
}
.options-hero {
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 0.72rem;
}
h1 {
margin: 0;
font-size: clamp(2.4rem, 7vw, 4rem);
}
.subtitle {
max-width: 640px;
color: var(--muted);
}
.panel {
margin-bottom: 18px;
padding: 24px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.section-heading {
margin-bottom: 18px;
}
.section-heading h2,
.section-heading p {
margin: 0;
}
.section-heading p {
margin-top: 8px;
color: var(--muted);
}
.bookmark-fields {
display: grid;
gap: 14px;
}
.bookmark-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 12px;
}
.toggle {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
}
.actions {
display: flex;
align-items: center;
gap: 16px;
}
#save-status {
margin: 0;
color: var(--muted);
}
@media (max-width: 720px) {
.bookmark-row {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column;
align-items: flex-start;
}
}

61
options.html Normal file
View file

@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Firefox Focus Mode Settings</title>
<link rel="stylesheet" href="theme.css">
<link rel="stylesheet" href="options.css">
</head>
<body>
<main class="options-shell">
<section class="options-hero">
<p class="eyebrow">Settings</p>
<h1>Focus Mode</h1>
<p class="subtitle">Choose up to four bookmarks and decide which extra blockers should be enabled.</p>
</section>
<form id="settings-form">
<section class="panel">
<div class="section-heading">
<h2>Bookmarks</h2>
<p>Only four appear on the home page.</p>
</div>
<div id="bookmark-fields" class="bookmark-fields"></div>
</section>
<section class="panel">
<div class="section-heading">
<h2>Blocking</h2>
<p>Tracker blocking is on by default. The other categories are optional.</p>
</div>
<label class="toggle">
<input type="checkbox" name="trackers">
<span>Block common trackers</span>
</label>
<label class="toggle">
<input type="checkbox" name="ads">
<span>Block ads</span>
</label>
<label class="toggle">
<input type="checkbox" name="analytics">
<span>Block analytics</span>
</label>
<label class="toggle">
<input type="checkbox" name="social">
<span>Block social widgets</span>
</label>
</section>
<div class="actions">
<button type="submit">Save Settings</button>
<p id="save-status" aria-live="polite"></p>
</div>
</form>
</main>
<script src="options.js"></script>
</body>
</html>

64
options.js Normal file
View file

@ -0,0 +1,64 @@
function buildBookmarkRows(bookmarks) {
const fields = document.getElementById("bookmark-fields");
fields.textContent = "";
bookmarks.forEach((bookmark, index) => {
const row = document.createElement("div");
row.className = "bookmark-row";
const title = document.createElement("input");
title.type = "text";
title.name = `bookmark-title-${index}`;
title.placeholder = `Bookmark ${index + 1} title`;
title.value = bookmark.title || "";
const url = document.createElement("input");
url.type = "text";
url.name = `bookmark-url-${index}`;
url.placeholder = `Bookmark ${index + 1} URL`;
url.value = bookmark.url || "";
row.append(title, url);
fields.append(row);
});
}
async function render() {
const settings = await browser.runtime.sendMessage({ type: "get-settings" });
buildBookmarkRows(settings.bookmarks);
document.querySelector('[name="trackers"]').checked = settings.blocking.trackers;
document.querySelector('[name="ads"]').checked = settings.blocking.ads;
document.querySelector('[name="analytics"]').checked = settings.blocking.analytics;
document.querySelector('[name="social"]').checked = settings.blocking.social;
}
document.getElementById("settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
const bookmarks = Array.from({ length: 4 }, (_, index) => ({
title: document.querySelector(`[name="bookmark-title-${index}"]`).value.trim(),
url: document.querySelector(`[name="bookmark-url-${index}"]`).value.trim()
}));
await browser.runtime.sendMessage({
type: "save-settings",
settings: {
bookmarks,
blocking: {
trackers: document.querySelector('[name="trackers"]').checked,
ads: document.querySelector('[name="ads"]').checked,
analytics: document.querySelector('[name="analytics"]').checked,
social: document.querySelector('[name="social"]').checked
}
}
});
const status = document.getElementById("save-status");
status.textContent = "Saved";
window.setTimeout(() => {
status.textContent = "";
}, 1500);
});
void render();

25
popup.css Normal file
View file

@ -0,0 +1,25 @@
body {
min-width: 320px;
}
.popup-shell {
padding: 16px;
}
.popup-url-form {
margin-bottom: 12px;
}
.popup-url-form input {
width: 100%;
}
.control-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.danger {
background: linear-gradient(135deg, #a42038, #d54c56);
}

29
popup.html Normal file
View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Firefox Focus Controls</title>
<link rel="stylesheet" href="theme.css">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<main class="popup-shell">
<form id="popup-url-form" class="popup-url-form">
<label class="sr-only" for="popup-url-input">Enter a URL or search</label>
<input id="popup-url-input" type="text" placeholder="URL or search">
</form>
<div class="control-grid">
<button data-action="back" type="button">Back</button>
<button data-action="forward" type="button">Forward</button>
<button data-action="refresh" type="button">Refresh</button>
<button data-action="home" type="button">Home</button>
<button data-action="trash" type="button" class="danger">Trash</button>
<button data-action="settings" type="button">Settings</button>
</div>
</main>
<script src="popup.js"></script>
</body>
</html>

60
popup.js Normal file
View file

@ -0,0 +1,60 @@
function normalizeUrl(rawUrl) {
if (!rawUrl) {
return "";
}
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl)) {
return rawUrl;
}
if (rawUrl.includes(" ") && !rawUrl.includes(".")) {
return `https://duckduckgo.com/?q=${encodeURIComponent(rawUrl)}`;
}
return `https://${rawUrl}`;
}
async function getActiveTab() {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
return tab;
}
document.getElementById("popup-url-form").addEventListener("submit", async (event) => {
event.preventDefault();
const input = document.getElementById("popup-url-input");
const value = input.value.trim();
if (!value) {
return;
}
await browser.runtime.sendMessage({ type: "open-url", url: normalizeUrl(value) });
window.close();
});
document.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", async () => {
const action = button.dataset.action;
const tab = await getActiveTab();
if (!tab) {
return;
}
if (action === "back") {
await browser.tabs.goBack(tab.id).catch(() => {});
} else if (action === "forward") {
await browser.tabs.goForward(tab.id).catch(() => {});
} else if (action === "refresh") {
await browser.tabs.reload(tab.id);
} else if (action === "home") {
await browser.runtime.sendMessage({ type: "go-home" });
} else if (action === "trash") {
await browser.runtime.sendMessage({ type: "clear-session" });
} else if (action === "settings") {
await browser.runtime.sendMessage({ type: "open-settings" });
}
window.close();
});
});

25
spec.md Normal file
View file

@ -0,0 +1,25 @@
# Summary
firefox focus is an ios and android app released by mozilla.
I want to create a firefox extension to make firefox similar to the "firefox focus" app, which is a simplified, privacy-focused browser experience.
# Features
The extension will implement the following features:
- only one tab ever. no new windows, no new tabs
- always incognito. there is no non-private mode. When you close the app, all data is deleted. When you open it again, it's like a fresh install.
- only four bookmarks max, and all on the home page. no bookmark manager, no folders, no syncing, no import/export
- tracking and script blocking is on by default, and can optionally block ads, analytics, and social scripts and trackers
- only five buttons: back, forward, refresh, url bar, trash, and settings. The trash button clears the session and goes to the home page again
# Implementation
There is a plugin at ../I-Hate-Tabs---SDI-extension that implements blocking opening new tabs and always opens them in a new window. We can use that to cause new tabs or windows to open in the current window instead.
There should be a way with an extension to add the four bookmark tiles to the home page, and to make the trash button clear the session and go to the home page again. We can use the extension API to implement these features.
I still want to have 1password, and maybe a few other extensions so I'm not going to block installing extensions, but maybe there can be a whitelist of allowed extensions that can be installed.
This extension would not be installed in the main profile, but in a secondary profile that could be launched with a shortcut. This way, the main profile can still be used for regular browsing, and the secondary profile can be used for the firefox focus experience. The extension would be installed in the secondary profile and would only affect that profile.

100
theme.css Normal file
View file

@ -0,0 +1,100 @@
:root {
--bg: #0d1622;
--bg-accent: #182739;
--surface: rgba(7, 16, 26, 0.7);
--text: #f5f1e7;
--muted: #afbdcb;
--accent: #ffbe5c;
--accent-strong: #f97316;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
color-scheme: dark;
font-family: "Avenir Next", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(249, 115, 22, 0.22), transparent 28%),
radial-gradient(circle at top right, rgba(255, 190, 92, 0.16), transparent 30%),
linear-gradient(160deg, var(--bg) 0%, var(--bg-accent) 100%);
color: var(--text);
}
body::before {
content: "";
position: fixed;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: radial-gradient(circle at center, black, transparent 78%);
pointer-events: none;
}
main {
position: relative;
z-index: 1;
}
input,
button {
border: 0;
border-radius: 18px;
font: inherit;
}
input {
padding: 15px 18px;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
input::placeholder {
color: #8f9dae;
}
button {
padding: 14px 18px;
background: linear-gradient(135deg, var(--accent-strong), var(--accent));
color: #20160d;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow);
}
button:hover,
button:focus-visible,
input:focus-visible,
a:focus-visible {
outline: 2px solid rgba(255, 190, 92, 0.9);
outline-offset: 2px;
}
.ghost-button {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
box-shadow: none;
}
.clear-button {
background: linear-gradient(135deg, #a42038, #d54c56);
color: #fff6f7;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}