src/background.ts
Rama main · réplica de lectura del repositorio original.
import browser from "webextension-polyfill";
import { beginOAuthSignIn, clearOAuthSession, getOAuthUser, OAuthUnavailableError, toOAuthIdentity } from "@/auth/oauth-session";
import { getOptionsConnectionState, reconcilePopupState } from "@/background/auth-session";
import {
clearActiveConnectionConfig,
resolveActiveConnection,
selectUseMemosSource,
type VerifiedConnection,
verifyAndActivateDirectConnection,
verifyAndActivateUseMemosConnection,
} from "@/background/connection-source";
import { clearMemoSaveAttempts, savePopupMemo, saveSelectionClip } from "@/background/memo-save";
import { isTrustedBackgroundRequest, parseBackgroundRequest, type RuntimeSender } from "@/lib/background-protocol";
import { findLatestClipStatus, listClipRecords } from "@/lib/clip-records";
import type { ConnectionSource } from "@/lib/connection-config";
import {
ClientError,
describeSaveError,
InstanceError,
type SaveErrorKind,
type SaveFailure,
toSaveErrorKind,
UnsupportedVersionError,
} from "@/lib/errors";
import { applyLocalePreference, getTextDirection, initializeLocalePreference, LOCALE_PREFERENCE_KEY, t, tp } from "@/lib/i18n";
import { clearCachedVersion, resolveVersion } from "@/lib/instance-version";
import { listSpaces, memosUserDisplayName } from "@/lib/memos-client";
import type { ConnectionStateResult, Request, SaveResult, SelectionClip, SpacesResult } from "@/lib/messages";
import { clearPopupState } from "@/lib/popup-state";
import { readClipTemplate } from "@/lib/template-settings";
import { isSupportedVersion, supportsSpaces } from "@/lib/versions";
type RestrictableStorageArea = typeof browser.storage.local & {
setAccessLevel?: (options: { accessLevel: "TRUSTED_CONTEXTS" }) => Promise<void>;
};
const localeReady = initializeLocalePreference();
// storage.local is exposed to content scripts by default. No content script in this
// extension needs storage, so keep OAuth and Memos credentials in trusted contexts only.
async function restrictLocalStorage(): Promise<void> {
try {
await (browser.storage.local as RestrictableStorageArea).setAccessLevel?.({ accessLevel: "TRUSTED_CONTEXTS" });
} catch {
// Firefox versions without this Chromium API still keep page scripts outside the
// content-script isolated world; the optional call is defense in depth where available.
}
}
void restrictLocalStorage();
/** Runs Clerk's public OAuth application through the browser identity API with PKCE. */
async function openSignInFlow(): Promise<void> {
const user = await beginOAuthSignIn();
await reconcilePopupState(user);
await broadcastAuthChanged();
await browser.runtime.openOptionsPage();
}
async function broadcastAuthChanged(): Promise<void> {
const authChanged: Request = { type: "AUTH_CHANGED" };
await browser.runtime.sendMessage(authChanged).catch(() => {});
}
async function getAuthIdentity() {
const user = await getOAuthUser();
return user ? toOAuthIdentity(user) : null;
}
function stateFromVerifiedConnection(connection: VerifiedConnection): ConnectionStateResult {
const displayName = connection.source === "direct" ? memosUserDisplayName(connection.user) : connection.displayName;
return {
source: connection.source,
instanceUrl: connection.credentials.instanceUrl,
version: connection.version,
displayName,
status: "ready",
verificationError: null,
isUsingCachedVersion: true,
};
}
async function finishConnectionActivation(connection: VerifiedConnection): Promise<void> {
await Promise.allSettled([clearPopupState(), clearMemoSaveAttempts(), ...(connection.source === "direct" ? [clearOAuthSession()] : [])]);
await reconcilePopupState().catch(() => undefined);
await broadcastAuthChanged();
}
async function activateConnection(verify: () => Promise<VerifiedConnection>) {
let connection: VerifiedConnection;
try {
connection = await verify();
} catch (error) {
return {
ok: false,
errorKind: toSaveErrorKind(error),
...(error instanceof UnsupportedVersionError ? { version: error.version } : {}),
} as const;
}
await finishConnectionActivation(connection);
return { ok: true, state: stateFromVerifiedConnection(connection) } as const;
}
browser.runtime.onMessage.addListener((message: unknown, sender: RuntimeSender) => {
const req = parseBackgroundRequest(message);
if (!req || !isTrustedBackgroundRequest(req, sender, browser.runtime.id)) return undefined;
if (req.type === "GET_POPUP_STATE") return reconcilePopupState();
if (req.type === "LIST_SPACES") {
return (async (): Promise<SpacesResult> => {
try {
const connection = await resolveActiveConnection();
if (!connection) return { ok: false, errorKind: "not-configured" };
if (
connection.source !== req.expectedSource ||
connection.connectionId !== req.expectedConnectionId ||
connection.credentials.instanceUrl !== req.expectedInstanceUrl
) {
return { ok: false, errorKind: "auth-changed" };
}
const version = await resolveVersion(connection.credentials);
if (!version) return { ok: false, errorKind: "bad-response" };
if (!supportsSpaces(version)) return { ok: true, spaces: [] };
return { ok: true, spaces: await listSpaces(connection.credentials) };
} catch (error) {
return { ok: false, errorKind: toSaveErrorKind(error) };
}
})();
}
if (req.type === "LIST_CLIP_RECORDS") return listClipRecords();
if (req.type === "GET_CLIP_STATUS") {
return (async () => {
try {
const connection = await resolveActiveConnection();
if (
!connection ||
connection.source !== req.expectedSource ||
connection.connectionId !== req.expectedConnectionId ||
connection.credentials.instanceUrl !== req.expectedInstanceUrl
) {
return null;
}
return await findLatestClipStatus(connection, req.sourceUrl);
} catch {
// History lookup is an enhancement; a failed lookup must not block clipping.
return null;
}
})();
}
if (req.type === "GET_AUTH_USER") return getAuthIdentity();
if (req.type === "GET_CONNECTION_STATE") return getOptionsConnectionState(req.refresh ?? true, req.source ?? "active");
if (req.type === "SELECT_USEMEMOS_SOURCE") {
return selectUseMemosSource().then(broadcastAuthChanged);
}
if (req.type === "CONNECT_DIRECT") return activateConnection(() => verifyAndActivateDirectConnection(req));
if (req.type === "ACTIVATE_USEMEMOS_CONNECTION") return activateConnection(verifyAndActivateUseMemosConnection);
if (req.type === "DISCONNECT_CONNECTION") {
return (async () => {
const source = await clearActiveConnectionConfig();
if (source !== "direct") await clearOAuthSession();
await Promise.all([clearPopupState(), clearCachedVersion(), clearMemoSaveAttempts()]);
await broadcastAuthChanged();
})();
}
if (req.type === "SAVE_MEMO") {
const requestId = req.saveRequestId ?? `legacy_${Date.now()}_${Math.random().toString(36).slice(2)}`;
return savePopupMemo(
req.content,
req.visibility,
req.images ?? [],
{
source: req.expectedSource,
connectionId: req.expectedConnectionId,
instanceUrl: req.expectedInstanceUrl,
},
{
requestId,
startedAt: req.saveStartedAt ?? Date.now(),
...(req.saveRequestId || req.clip ? { serverMemoId: requestId } : {}),
},
req.clip,
req.space,
);
}
if (req.type === "OPEN_SIGN_IN") return openSignInFlow();
if (req.type === "SIGN_OUT") {
return (async () => {
const source = await clearActiveConnectionConfig({ preserveDirect: true });
await clearOAuthSession();
await Promise.all([clearPopupState(), ...(source !== "direct" ? [clearCachedVersion(), clearMemoSaveAttempts()] : [])]);
await broadcastAuthChanged();
})();
}
return undefined;
});
/** Flash a transient status badge on the toolbar icon (used by the context-menu quick-save). */
// The toolbar badge can't read CSS variables, and Chrome parses neither oklch() nor color-mix(),
// so these are @usememos/ui's light `success` and `danger` (oklch 0.52 0.13 150 / 0.53 0.19 27)
// in sRGB. Update them with the ui pin.
const BADGE_SUCCESS: [number, number, number, number] = [29, 125, 62, 255];
const BADGE_DANGER: [number, number, number, number] = [194, 40, 38, 255];
async function flashBadge(text: string, color: [number, number, number, number]): Promise<void> {
await Promise.all([browser.action.setBadgeBackgroundColor({ color }), browser.action.setBadgeText({ text })]);
setTimeout(() => void browser.action.setBadgeText({ text: "" }), 4000);
}
// One contextual item, shown on both a text selection and an image (never on a blank right-click).
// removeAll-then-create makes registration idempotent so it can run both on install and on every
// service-worker startup (menus can be lost when the SW is replaced, e.g. during development).
async function applyBrowserUi(): Promise<void> {
await localeReady;
await Promise.all([
browser.action.setTitle({ title: t("actionTitle") }),
(async () => {
await browser.contextMenus.removeAll();
browser.contextMenus.create({ id: "save-selection", title: t("contextMenuSaveSelection"), contexts: ["selection", "image"] });
})(),
]);
}
// Install, worker startup, and a locale change can overlap (on install, the top-level call and the
// onInstalled listener run in the same worker). Two interleaved removeAll/create pairs would both
// remove, then both create, and the second create fails with a duplicate-id error — so run them
// one at a time.
let browserUiUpdate: Promise<void> = Promise.resolve();
function localizeBrowserUi(): Promise<void> {
const run = browserUiUpdate.then(applyBrowserUi, applyBrowserUi);
browserUiUpdate = run.catch(() => undefined);
return run;
}
browser.runtime.onInstalled.addListener(() => localizeBrowserUi());
void localizeBrowserUi();
browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "local" || !changes[LOCALE_PREFERENCE_KEY]) return undefined;
applyLocalePreference(changes[LOCALE_PREFERENCE_KEY].newValue);
return localizeBrowserUi();
});
/** After a save, drop the page's selection/focus (best-effort; no-op on pages without the content script). */
async function clearTabSelection(tabId: number | undefined): Promise<void> {
if (tabId === undefined) return;
const clear: Request = { type: "CLEAR_SELECTION" };
await browser.tabs.sendMessage(tabId, clear).catch(() => {});
}
/** Success copy that never hides a partial failure: a dropped image is named, not swallowed. */
function saveSuccessTitle(failedImages?: number): string {
if (!failedImages) return t("popupSavedToMemos");
return tp("contextSavePartial", failedImages);
}
/** In-page toast with the save outcome (best-effort; the toolbar badge is the fallback on pages without the content script). */
async function showSaveResultInTab(tabId: number | undefined, result: SaveResult, source?: "direct" | "usememos"): Promise<void> {
if (tabId === undefined) return;
await localeReady;
// The in-page toast has no buttons, so the fix rides along in the text ("… — Sign in and reconnect.").
const failureTitle = (failure: SaveFailure): string => {
const detail = describeSaveError(failure.errorKind, source, failure);
return detail.howToFix[0] ? t("contextFailureWithFix", [detail.title, detail.howToFix[0]]) : detail.title;
};
const msg: Request = result.ok
? {
type: "SHOW_SAVE_RESULT",
ok: true,
title: saveSuccessTitle(result.failedImages),
webUrl: result.webUrl,
openLabel: t("commonOpen"),
direction: getTextDirection(),
}
: {
type: "SHOW_SAVE_RESULT",
ok: false,
title: failureTitle(result),
direction: getTextDirection(),
};
await browser.tabs.sendMessage(tabId, msg).catch(() => {});
}
/**
* Renders the selection for saving. The content script (which has a DOM — the service worker does
* not) converts the selection to markdown and extracts its image URLs, so a text+image selection
* keeps its text and carries the images as real attachments. Falls back to Chrome's plain
* `info.selectionText` (no images) when the content script isn't reachable.
*/
async function getSelectionClip(tabId: number | undefined, fallbackText: string): Promise<SelectionClip> {
if (tabId !== undefined) {
try {
const get: Request = { type: "GET_SELECTION" };
const clip = (await Promise.race([
browser.tabs.sendMessage(tabId, get),
new Promise<undefined>((resolve) => setTimeout(resolve, 2_000)),
])) as SelectionClip | undefined;
if (clip?.markdown) return clip;
} catch {
// No content script here — fall through to the plain text Chrome already handed us.
}
}
return { markdown: fallbackText, images: [] };
}
browser.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== "save-selection") return;
// The same item covers both contexts. A bare image (srcUrl, no selection) is uploaded as an
// attachment; anything with a text selection — including a selection that contains images — is
// saved as the whole selection (see getSelectionClip), so mixed text+image isn't lost.
const selectionText = info.selectionText?.trim();
const isImage = Boolean(info.srcUrl) && !selectionText;
if (!isImage && !selectionText) return;
// Start rendering the clip immediately — it only needs the tab, not the auth/version gate,
// and its content-script round-trip (up to 2s) is the slow part of a quick-save. When the
// gate fails below, the one wasted GET_SELECTION message is harmless. (getSelectionClip
// never rejects, so an early gate return can't leave an unhandled rejection.)
const clipPromise =
isImage && info.srcUrl
? Promise.resolve({ markdown: "", images: [info.srcUrl] }) // a bare image is just a clip with one image
: getSelectionClip(tab?.id, selectionText ?? "");
// The item follows the same gate as the popup. When a setup gate isn't met we open the place
// that resolves it (opening the tab is the feedback).
let result: SaveResult;
let source: ConnectionSource | undefined;
try {
const connection = await resolveActiveConnection();
if (!connection) {
await browser.runtime.openOptionsPage();
return;
}
source = connection.source;
const { credentials } = connection;
// Version is a per-device cache (see instance-version.ts); resolve it, self-populating on a
// device that connected on another machine but never verified here.
const version = await resolveVersion(credentials);
if (!version || !isSupportedVersion(version)) {
await browser.runtime.openOptionsPage();
return;
}
const template = await readClipTemplate();
const title = tab?.title ?? "";
const url = info.pageUrl ?? tab?.url ?? "";
result = await saveSelectionClip(await clipPromise, title, url, credentials, template);
} catch (error) {
// A quick-save has no UI of its own, so anything that throws on the way to the save — an OAuth
// outage, a failed storage read, an unexpected error — must still surface as a failed save
// rather than as a silent no-op with only a console entry behind it.
result = { ok: false, errorKind: quickSaveErrorKind(error) };
}
if (!result.ok) {
console.warn("[memos-web-clipper] context-menu save failed:", describeSaveError(result.errorKind).title);
}
// The three outcome signals are independent — badge, selection cleanup, and in-page toast.
await Promise.all([
result.ok ? clearTabSelection(tab?.id) : Promise.resolve(),
result.ok ? flashBadge("✓", BADGE_SUCCESS) : flashBadge("!", BADGE_DANGER),
showSaveResultInTab(tab?.id, result, source),
]);
});
/** Maps a throw from the quick-save gate to user-facing copy; unknown errors are the extension's own fault, not the instance's. */
function quickSaveErrorKind(error: unknown): SaveErrorKind {
if (error instanceof OAuthUnavailableError) return "auth-unavailable";
if (error instanceof InstanceError || error instanceof ClientError) return error.kind;
return "extension-error";
}