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
32 changes: 26 additions & 6 deletions apps/papillon-extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import { parsePapUri, httpsUrlToPap } from "../lib/uri.js";
import { NATIVE_APP_ID } from "../lib/native-messaging.js";
import type { PapManifest } from "../lib/discovery.js";
import type {
ExtensionMessage,
Expand All @@ -18,6 +19,17 @@ import type {
PapSiteResponse,
} from "../lib/types.js";

// ── Local constants ─────────────────────────────────────────────────────

/** Badge background color when sessions are active (CSS --gold). */
const BADGE_COLOR_GOLD = "#f0a030";

/** Badge background color for PAP-capable sites (CSS --purple). */
const BADGE_COLOR_PURPLE = "#6c5ce7";

/** Context menu item ID for "Open with PAP protection". */
const CONTEXT_MENU_UPGRADE_ID = "pap-upgrade-link";

// ── State ──────────────────────────────────────────────────────────────

const activeSessions = new Map<string, ActiveSession>();
Expand Down Expand Up @@ -119,16 +131,14 @@ function updateBadge() {
const count = activeSessions.size;
if (count > 0) {
chrome.action.setBadgeText({ text: String(count) });
chrome.action.setBadgeBackgroundColor({ color: "#f0a030" }); // --gold
chrome.action.setBadgeBackgroundColor({ color: BADGE_COLOR_GOLD });
} else {
chrome.action.setBadgeText({ text: "" });
}
}

// ── Native Messaging ───────────────────────────────────────────────────

const NATIVE_APP_ID = "com.baur_software.papillon";

function connectNative(): chrome.runtime.Port | null {
try {
const port = chrome.runtime.connectNative(NATIVE_APP_ID);
Expand Down Expand Up @@ -161,6 +171,16 @@ chrome.runtime.onMessage.addListener(
openHandshakeTab(msg.uri);
break;

// Content script: user left-clicked an https:// link (auto-intercept)
case "HTTPS_LINK_CLICKED": {
// Convert https://example.com/path → pap://example.com/path
// resolve_pap_uri() treats dotted authorities as HttpsEndpoint, so
// pap:// is the canonical form; pap+https:// is the transport detail.
const papUri = msg.httpsUrl.replace(/^https:\/\//, "pap://");
openHandshakeTab(papUri, undefined, undefined, msg.httpsUrl);
break;
}

// Handshake page: start the protocol
case "START_HANDSHAKE":
ensureOffscreen().then(() => {
Expand Down Expand Up @@ -271,7 +291,7 @@ chrome.runtime.onMessage.addListener(
// overrides per-tab when set; when global clears, per-tab shows through.
chrome.action.setBadgeText({ text: "PAP", tabId });
chrome.action.setBadgeBackgroundColor({
color: "#6c5ce7", // --purple
color: BADGE_COLOR_PURPLE,
tabId,
});
break;
Expand Down Expand Up @@ -365,7 +385,7 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {

if (chrome.contextMenus) {
chrome.contextMenus.onClicked.addListener((info) => {
if (info.menuItemId === "pap-upgrade-link" && info.linkUrl) {
if (info.menuItemId === CONTEXT_MENU_UPGRADE_ID && info.linkUrl) {
try {
const papUri = httpsUrlToPap(info.linkUrl);
openHandshakeTab(papUri, undefined, undefined, info.linkUrl);
Expand All @@ -389,7 +409,7 @@ chrome.runtime.onInstalled.addListener(async () => {
// Register context menu for HTTPS link upgrade (if API available)
if (chrome.contextMenus) {
chrome.contextMenus.create({
id: "pap-upgrade-link",
id: CONTEXT_MENU_UPGRADE_ID,
title: "Open with PAP protection",
contexts: ["link"],
targetUrlPatterns: ["https://*/*"],
Expand Down
78 changes: 77 additions & 1 deletion apps/papillon-extension/src/content/content-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,53 @@
*/

import { fetchManifest } from "../lib/discovery.js";
import { resolveInterceptUrl } from "./intercept-logic.js";
import { STORAGE_AUTO_INTERCEPT, STORAGE_EXCLUDED_DOMAINS } from "../lib/constants.js";

const PAP_SCHEMES = ["pap://", "pap+https://", "pap+wss://"];

// ── Auto-intercept HTTPS links ──────────────────────────────────────────

/** Live-updated from chrome.storage.sync. Default: on. */
let autoInterceptEnabled = true;

/** Live-updated from chrome.storage.sync. Hostname strings only. */
let excludedDomains = new Set<string>();

// Load initial values from storage before any click can arrive.
chrome.storage.sync.get(
[STORAGE_AUTO_INTERCEPT, STORAGE_EXCLUDED_DOMAINS],
(result) => {
if (typeof result[STORAGE_AUTO_INTERCEPT] === "boolean") {
autoInterceptEnabled = result[STORAGE_AUTO_INTERCEPT] as boolean;
}
if (Array.isArray(result[STORAGE_EXCLUDED_DOMAINS])) {
excludedDomains = new Set<string>(
(result[STORAGE_EXCLUDED_DOMAINS] as unknown[]).filter(
(d): d is string => typeof d === "string"
)
);
}
}
);

// Keep in-memory state in sync when the user changes settings in the popup.
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync") return;
if (STORAGE_AUTO_INTERCEPT in changes) {
const next = changes[STORAGE_AUTO_INTERCEPT].newValue;
if (typeof next === "boolean") autoInterceptEnabled = next;
}
if (STORAGE_EXCLUDED_DOMAINS in changes) {
const next = changes[STORAGE_EXCLUDED_DOMAINS].newValue;
if (Array.isArray(next)) {
excludedDomains = new Set<string>(
(next as unknown[]).filter((d): d is string => typeof d === "string")
);
}
}
});

function isPapLink(el: HTMLAnchorElement): boolean {
const href = el.getAttribute("href");
if (!href) return false;
Expand Down Expand Up @@ -51,6 +95,36 @@ function interceptClick(e: MouseEvent) {
});
}

/**
* Auto-intercept plain left-clicks on https:// links and route through PAP.
* Guard logic is in resolveInterceptUrl (intercept-logic.ts) for testability.
*/
function interceptHttpsClick(e: MouseEvent): void {
const link = (e.target as HTMLElement).closest("a") as HTMLAnchorElement | null;
const rawHref = link?.getAttribute("href") ?? null;
const hasDownload = link?.hasAttribute("download") ?? false;

const url = resolveInterceptUrl(
e,
rawHref,
hasDownload,
document.baseURI,
autoInterceptEnabled,
excludedDomains
);
if (!url) return;

e.preventDefault();
e.stopPropagation();

chrome.runtime.sendMessage({
type: "HTTPS_LINK_CLICKED",
httpsUrl: url,
pageTitle: document.title,
pageUrl: window.location.href,
});
}

function scanLinks(root: ParentNode = document) {
const links = root.querySelectorAll<HTMLAnchorElement>("a[href]");
for (const link of links) {
Expand Down Expand Up @@ -85,9 +159,10 @@ observer.observe(document.body, {
subtree: true,
});

// ── Click handler ──────────────────────────────────────────────────────
// ── Click handlers ─────────────────────────────────────────────────────

document.addEventListener("click", interceptClick, true);
document.addEventListener("click", interceptHttpsClick, true);

// ── Layer 0+1: PAP site discovery ─────────────────────────────────────

Expand Down Expand Up @@ -154,4 +229,5 @@ if (proto === "https:" || proto === "http:") {
window.addEventListener("pagehide", () => {
observer.disconnect();
document.removeEventListener("click", interceptClick, true);
document.removeEventListener("click", interceptHttpsClick, true);
});
Loading
Loading