commit 6c32ef2323107b4e45b9cb211669bb8315547007 Author: Hunter Haugen Date: Mon Apr 6 12:46:20 2026 -0700 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f9a15d --- /dev/null +++ b/README.md @@ -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`. diff --git a/background.js b/background.js new file mode 100644 index 0000000..1e5353e --- /dev/null +++ b/background.js @@ -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: [""] }, + ["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(); diff --git a/firefox-focus b/firefox-focus new file mode 100755 index 0000000..abcbb4a --- /dev/null +++ b/firefox-focus @@ -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 <"$user_js_path" <"$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 \ + "$@" diff --git a/home.css b/home.css new file mode 100644 index 0000000..3c60f36 --- /dev/null +++ b/home.css @@ -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; + } +} diff --git a/home.html b/home.html new file mode 100644 index 0000000..68c996c --- /dev/null +++ b/home.html @@ -0,0 +1,43 @@ + + + + + + Firefox Focus Mode + + + + +
+
+

Privacy-first browsing

+

Firefox Focus Mode

+

One tab, a fresh session, and just the essentials.

+
+ +
+ + + +
+ +
+
+

Bookmarks

+ +
+
+
+ +
+
+

Erase Session

+

Delete browsing data, close extra tabs, and return here.

+
+ +
+
+ + + + diff --git a/home.js b/home.js new file mode 100644 index 0000000..fdf88ac --- /dev/null +++ b/home.js @@ -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(); diff --git a/icons/focus-16.svg b/icons/focus-16.svg new file mode 100644 index 0000000..b9f9a83 --- /dev/null +++ b/icons/focus-16.svg @@ -0,0 +1 @@ + diff --git a/icons/focus-32.svg b/icons/focus-32.svg new file mode 100644 index 0000000..b9f9a83 --- /dev/null +++ b/icons/focus-32.svg @@ -0,0 +1 @@ + diff --git a/icons/focus-48.svg b/icons/focus-48.svg new file mode 100644 index 0000000..b9f9a83 --- /dev/null +++ b/icons/focus-48.svg @@ -0,0 +1 @@ + diff --git a/icons/focus-96.svg b/icons/focus-96.svg new file mode 100644 index 0000000..b9f9a83 --- /dev/null +++ b/icons/focus-96.svg @@ -0,0 +1 @@ + diff --git a/icons/old/focus-16.svg b/icons/old/focus-16.svg new file mode 100644 index 0000000..63aa9ac --- /dev/null +++ b/icons/old/focus-16.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/old/focus-32.svg b/icons/old/focus-32.svg new file mode 100644 index 0000000..6b826e7 --- /dev/null +++ b/icons/old/focus-32.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/old/focus-48.svg b/icons/old/focus-48.svg new file mode 100644 index 0000000..c4bbce5 --- /dev/null +++ b/icons/old/focus-48.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/old/focus-96.svg b/icons/old/focus-96.svg new file mode 100644 index 0000000..45d0cd6 --- /dev/null +++ b/icons/old/focus-96.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3f8860c --- /dev/null +++ b/manifest.json @@ -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", + "" + ], + "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" + } + } +} diff --git a/options.css b/options.css new file mode 100644 index 0000000..83b1699 --- /dev/null +++ b/options.css @@ -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; + } +} diff --git a/options.html b/options.html new file mode 100644 index 0000000..0749b21 --- /dev/null +++ b/options.html @@ -0,0 +1,61 @@ + + + + + + Firefox Focus Mode Settings + + + + +
+
+

Settings

+

Focus Mode

+

Choose up to four bookmarks and decide which extra blockers should be enabled.

+
+ +
+
+
+

Bookmarks

+

Only four appear on the home page.

+
+ +
+
+ +
+
+

Blocking

+

Tracker blocking is on by default. The other categories are optional.

+
+ + + + + +
+ +
+ +

+
+
+
+ + + + diff --git a/options.js b/options.js new file mode 100644 index 0000000..476c179 --- /dev/null +++ b/options.js @@ -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(); diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..5e1dbc8 --- /dev/null +++ b/popup.css @@ -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); +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..279953b --- /dev/null +++ b/popup.html @@ -0,0 +1,29 @@ + + + + + + Firefox Focus Controls + + + + +
+ + +
+ + + + + + +
+
+ + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..03ebdc0 --- /dev/null +++ b/popup.js @@ -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(); + }); +}); diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..c2a7d12 --- /dev/null +++ b/spec.md @@ -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. diff --git a/theme.css b/theme.css new file mode 100644 index 0000000..e803c81 --- /dev/null +++ b/theme.css @@ -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; +}