harper/packages/chrome-plugin/src/background/index.ts
Elijah Potter b033b9bf6d
Some checks are pending
Binaries / harper-cli - macOS-aarch64 (push) Waiting to run
Binaries / harper-cli - Linux-aarch64-GNU (push) Waiting to run
Binaries / harper-cli - Linux-aarch64-musl (push) Waiting to run
Binaries / harper-cli - macOS-x86_64 (push) Waiting to run
Binaries / harper-cli - Linux-x86_64-GNU (push) Waiting to run
Binaries / harper-cli - Linux-x86_64-musl (push) Waiting to run
Binaries / harper-cli - Windows-x86_64 (push) Waiting to run
Binaries / harper-ls - macOS-aarch64 (push) Waiting to run
Binaries / harper-ls - Linux-aarch64-GNU (push) Waiting to run
Binaries / harper-ls - Linux-aarch64-musl (push) Waiting to run
Binaries / harper-ls - macOS-x86_64 (push) Waiting to run
Binaries / harper-ls - Linux-x86_64-GNU (push) Waiting to run
Binaries / harper-ls - Linux-x86_64-musl (push) Waiting to run
Binaries / harper-ls - Windows-x86_64 (push) Waiting to run
Build Web / build-web (push) Waiting to run
Chrome Plugin / chrome-plugin (push) Waiting to run
Just Checks / just build-obsidian (push) Waiting to run
Just Checks / just test-harperjs (push) Waiting to run
Just Checks / just test-obsidian (push) Waiting to run
Just Checks / just test-rust (push) Waiting to run
Just Checks / just test-vscode (push) Waiting to run
VS Code Plugin / alpine-arm64 (push) Waiting to run
VS Code Plugin / darwin-arm64 (push) Waiting to run
VS Code Plugin / linux-armhf (push) Waiting to run
VS Code Plugin / linux-x64 (push) Waiting to run
WordPress Plugin / wp-plugin (push) Waiting to run
Just Checks / just check-js (push) Waiting to run
Just Checks / just check-rust (push) Waiting to run
Just Checks / just test-chrome-plugin (push) Waiting to run
Just Checks / just test-firefox-plugin (push) Waiting to run
VS Code Plugin / alpine-x64 (push) Waiting to run
VS Code Plugin / darwin-x64 (push) Waiting to run
VS Code Plugin / linux-arm64 (push) Waiting to run
VS Code Plugin / win32-arm64 (push) Waiting to run
VS Code Plugin / win32-x64 (push) Waiting to run
chore: reduce dependency load for new contributors (#2131)
2025-11-04 13:53:01 -07:00

453 lines
13 KiB
TypeScript

import { BinaryModule, Dialect, type LintConfig, LocalLinter } from 'harper.js';
import { type UnpackedLintGroups, unpackLint } from 'lint-framework';
import type { PopupState } from '../PopupState';
import {
ActivationKey,
type AddToUserDictionaryRequest,
createUnitResponse,
type GetActivationKeyResponse,
type GetConfigRequest,
type GetConfigResponse,
type GetDefaultStatusResponse,
type GetDialectRequest,
type GetDialectResponse,
type GetDomainStatusRequest,
type GetDomainStatusResponse,
type GetEnabledDomainsResponse,
type GetLintDescriptionsRequest,
type GetLintDescriptionsResponse,
type GetUserDictionaryResponse,
type IgnoreLintRequest,
type LintRequest,
type LintResponse,
type OpenReportErrorRequest,
type PostFormDataRequest,
type PostFormDataResponse,
type Request,
type Response,
type SetActivationKeyRequest,
type SetConfigRequest,
type SetDefaultStatusRequest,
type SetDialectRequest,
type SetDomainStatusRequest,
type SetUserDictionaryRequest,
type UnitResponse,
} from '../protocol';
console.log('background is running');
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) {
chrome.runtime.setUninstallURL('https://writewithharper.com/uninstall-browser-extension');
chrome.tabs.create({ url: 'https://writewithharper.com/install-browser-extension' });
}
});
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
handleRequest(request).then(sendResponse);
return true;
});
let linter: LocalLinter;
getDialect().then(setDialect);
async function enableDefaultDomains() {
const defaultEnabledDomains = [
'old.reddit.com',
'chatgpt.com',
'www.perplexity.ai',
'textarea.online',
'webmail.porkbun.com',
'mail.google.com',
'trix-editor.org',
'github.com',
'messages.google.com',
'blank.page',
'blankpage.im',
'froala.com',
'playground.lexical.dev',
'discord.com',
'www.youtube.com',
'www.google.com',
'www.instagram.com',
'web.whatsapp.com',
'outlook.live.com',
'www.reddit.com',
'www.linkedin.com',
'bsky.app',
'pootlewriter.com',
'www.tumblr.com',
'dayone.me',
'medium.com',
'x.com',
'www.notion.so',
'hashnode.com',
'www.slatejs.org',
'localhost',
'writewithharper.com',
'prosemirror.net',
'draftjs.org',
'gitlab.com',
'core.trac.wordpress.org',
'write.ellipsus.com',
'www.facebook.com',
'www.upwork.com',
'news.ycombinator.com',
'classroom.google.com',
];
for (const item of defaultEnabledDomains) {
if (!(await isDomainSet(item))) {
setDomainEnable(item, true);
}
}
}
enableDefaultDomains();
function handleRequest(message: Request): Promise<Response> {
console.log(`Handling ${message.kind} request`);
switch (message.kind) {
case 'lint':
return handleLint(message);
case 'getConfig':
return handleGetConfig(message);
case 'setConfig':
return handleSetConfig(message);
case 'getLintDescriptions':
return handleGetLintDescriptions(message);
case 'setDialect':
return handleSetDialect(message);
case 'getDialect':
return handleGetDialect(message);
case 'getDomainStatus':
return handleGetDomainStatus(message);
case 'setDomainStatus':
return handleSetDomainStatus(message);
case 'addToUserDictionary':
return handleAddToUserDictionary(message);
case 'ignoreLint':
return handleIgnoreLint(message);
case 'setDefaultStatus':
return handleSetDefaultStatus(message);
case 'getDefaultStatus':
return handleGetDefaultStatus();
case 'getEnabledDomains':
return handleGetEnabledDomains();
case 'getUserDictionary':
return handleGetUserDictionary();
case 'setUserDictionary':
return handleSetUserDictionary(message);
case 'getActivationKey':
return handleGetActivationKey();
case 'setActivationKey':
return handleSetActivationKey(message);
case 'openReportError':
return handleOpenReportError(message);
case 'openOptions':
chrome.runtime.openOptionsPage();
return Promise.resolve(createUnitResponse());
case 'postFormData':
return handlePostFormData(message);
}
}
/** Handle a request for linting. */
async function handleLint(req: LintRequest): Promise<LintResponse> {
if (!(await enabledForDomain(req.domain))) {
return { kind: 'lints', lints: {} };
}
const grouped = await linter.organizedLints(req.text);
const unpackedEntries = await Promise.all(
Object.entries(grouped).map(async ([source, lints]) => {
const unpacked = await Promise.all(lints.map((lint) => unpackLint(req.text, lint, linter)));
return [source, unpacked] as const;
}),
);
const unpackedBySource = Object.fromEntries(unpackedEntries) as UnpackedLintGroups;
return { kind: 'lints', lints: unpackedBySource };
}
async function handleGetConfig(_req: GetConfigRequest): Promise<GetConfigResponse> {
return { kind: 'getConfig', config: await getLintConfig() };
}
async function handleSetConfig(req: SetConfigRequest): Promise<UnitResponse> {
await setLintConfig(req.config);
return createUnitResponse();
}
async function handleSetDialect(req: SetDialectRequest): Promise<UnitResponse> {
await setDialect(req.dialect);
return createUnitResponse();
}
async function handleGetDialect(_req: GetDialectRequest): Promise<GetDialectResponse> {
return { kind: 'getDialect', dialect: await getDialect() };
}
async function handleIgnoreLint(req: IgnoreLintRequest): Promise<UnitResponse> {
await linter.ignoreLintHash(BigInt(req.contextHash));
await setIgnoredLints(await linter.exportIgnoredLints());
return createUnitResponse();
}
async function handleGetDefaultStatus(): Promise<GetDefaultStatusResponse> {
return {
kind: 'getDefaultStatus',
enabled: await enabledByDefault(),
};
}
async function handleGetEnabledDomains(): Promise<GetEnabledDomainsResponse> {
const all = await chrome.storage.local.get(null as any);
const prefix = formatDomainKey(''); // yields 'domainStatus '
const domains = Object.entries(all)
.filter(([k, v]) => typeof v === 'boolean' && v === true && k.startsWith(prefix))
.map(([k]) => k.substring(prefix.length))
.sort((a, b) => a.localeCompare(b));
return { kind: 'getEnabledDomains', domains };
}
async function handleGetDomainStatus(
req: GetDomainStatusRequest,
): Promise<GetDomainStatusResponse> {
return {
kind: 'getDomainStatus',
domain: req.domain,
enabled: await enabledForDomain(req.domain),
};
}
async function handleSetDomainStatus(req: SetDomainStatusRequest): Promise<UnitResponse> {
await setDomainEnable(req.domain, req.enabled, req.overrideValue);
return createUnitResponse();
}
async function handleSetDefaultStatus(req: SetDefaultStatusRequest): Promise<UnitResponse> {
await setDefaultEnable(req.enabled);
return createUnitResponse();
}
async function handleGetLintDescriptions(
_req: GetLintDescriptionsRequest,
): Promise<GetLintDescriptionsResponse> {
return { kind: 'getLintDescriptions', descriptions: await linter.getLintDescriptionsHTML() };
}
async function handleSetUserDictionary(req: SetUserDictionaryRequest): Promise<UnitResponse> {
await resetDictionary();
await addToDictionary(req.words);
return createUnitResponse();
}
async function handleAddToUserDictionary(req: AddToUserDictionaryRequest): Promise<UnitResponse> {
await addToDictionary(req.words);
return createUnitResponse();
}
async function handleGetUserDictionary(): Promise<GetUserDictionaryResponse> {
const dict = await getUserDictionary();
return { kind: 'getUserDictionary', words: dict };
}
async function handleGetActivationKey(): Promise<GetActivationKeyResponse> {
const key = await getActivationKey();
return { kind: 'getActivationKey', key };
}
async function handleSetActivationKey(req: SetActivationKeyRequest): Promise<UnitResponse> {
if (!Object.values(ActivationKey).includes(req.key)) {
throw new Error(`Invalid activation key: ${req.key}`);
}
await setActivationKey(req.key);
return createUnitResponse();
}
async function handleOpenReportError(req: OpenReportErrorRequest): Promise<UnitResponse> {
const popupState: PopupState = {
page: 'report-error',
example: req.example,
rule_id: req.rule_id,
feedback: req.feedback,
};
await chrome.storage.local.set({ popupState });
if (chrome.action?.openPopup) {
try {
await chrome.action.openPopup();
} catch (error) {
console.error('Failed to open popup for report error', error);
}
}
return createUnitResponse();
}
async function handlePostFormData(req: PostFormDataRequest): Promise<PostFormDataResponse> {
const formData = new FormData();
for (const [key, value] of Object.entries(req.formData)) {
formData.append(key, value);
}
try {
const response = await fetch(req.url, {
method: 'POST',
body: formData,
});
return { kind: 'postFormData', success: response.ok };
} catch (error) {
console.error('Failed to post form data', error);
return { kind: 'postFormData', success: false };
}
}
/** Set the lint configuration inside the global `linter` and in permanent storage. */
async function setLintConfig(lintConfig: LintConfig): Promise<void> {
await linter.setLintConfig(lintConfig);
const json = await linter.getLintConfigAsJSON();
await chrome.storage.local.set({ lintConfig: json });
}
/** Get the lint configuration from permanent storage. */
async function getLintConfig(): Promise<LintConfig> {
const json = await linter.getLintConfigAsJSON();
const resp = await chrome.storage.local.get({ lintConfig: json });
return JSON.parse(resp.lintConfig);
}
/** Get the ignored lint state from permanent storage. */
async function setIgnoredLints(state: string): Promise<void> {
await linter.importIgnoredLints(state);
const json = await linter.exportIgnoredLints();
await chrome.storage.local.set({ ignoredLints: json });
}
/** Get the ignored lint state from permanent storage. */
async function getIgnoredLints(): Promise<string> {
const state = await linter.exportIgnoredLints();
const resp = await chrome.storage.local.get({ ignoredLints: state });
return resp.ignoredLints;
}
async function getDialect(): Promise<Dialect> {
const resp = await chrome.storage.local.get({ dialect: Dialect.American });
return resp.dialect;
}
async function getActivationKey(): Promise<ActivationKey> {
const resp = await chrome.storage.local.get({ activationKey: ActivationKey.Off });
return resp.activationKey;
}
async function setActivationKey(key: ActivationKey) {
await chrome.storage.local.set({ activationKey: key });
}
function initializeLinter(dialect: Dialect) {
linter = new LocalLinter({
binary: new BinaryModule(chrome.runtime.getURL('./wasm/harper_wasm_bg.wasm')),
dialect,
});
getIgnoredLints().then((i) => linter.importIgnoredLints(i));
getUserDictionary().then((u) => linter.importWords(u));
getLintConfig().then((c) => linter.setLintConfig(c));
linter.setup();
}
async function setDialect(dialect: Dialect) {
await chrome.storage.local.set({ dialect });
initializeLinter(dialect);
}
/** Format the key to be used in local storage to store domain status. */
function formatDomainKey(domain: string): string {
return `domainStatus ${domain}`;
}
/** Check if Harper has been enabled for a given domain. */
async function enabledForDomain(domain: string): Promise<boolean | null> {
const req = await chrome.storage.local.get({
[formatDomainKey(domain)]: await enabledByDefault(),
});
return req[formatDomainKey(domain)];
}
/** Set whether Harper is enabled for a given domain.
*
* @param overrideValue dictates whether this should override a previous setting.
* */
async function setDomainEnable(domain: string, status: boolean, overrideValue = true) {
let shouldSet = !(await isDomainSet(domain));
if (overrideValue) {
shouldSet = true;
}
if (shouldSet) {
await chrome.storage.local.set({ [formatDomainKey(domain)]: status });
}
}
/** Set whether Harper is enabled by default. */
async function setDefaultEnable(status: boolean) {
await chrome.storage.local.set({ defaultEnable: status });
}
/** Check if Harper has been enabled by default. */
async function enabledByDefault(): Promise<boolean> {
const req = await chrome.storage.local.get({ defaultEnable: false });
return req.defaultEnable;
}
/** Check whether Harper's state has been set for a given domain. */
async function isDomainSet(domain: string): Promise<boolean> {
const resp = await chrome.storage.local.get(formatDomainKey(domain));
return typeof resp[formatDomainKey(domain)] == 'boolean';
}
/** Reset the persistent user dictionary. */
async function resetDictionary(): Promise<void> {
await chrome.storage.local.set({ userDictionary: null });
initializeLinter(await linter.getDialect());
}
/** Add words to the persistent user dictionary. */
async function addToDictionary(words: string[]): Promise<void> {
const exported = await linter.exportWords();
exported.push(...words);
await Promise.all([
linter.importWords(exported),
chrome.storage.local.set({ userDictionary: exported }),
]);
}
/** Grab the user dictionary from persistent storage. */
async function getUserDictionary(): Promise<string[]> {
const resp = await chrome.storage.local.get({ userDictionary: [] });
return resp.userDictionary;
}