Ctrl K
ComunidadGuías…

src/background.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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";
}