Skip to content
Closed
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
13 changes: 7 additions & 6 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { SHARED } from "../../src/shared/settings.js";

/**
* MV3 service worker:Chrome 閒置 ~30 秒即自動終結,天生不常駐記憶體。
* 佇列放在磁碟上的 chrome.storage.local(有上限),送達本機服務後即刪除。
* 唯一通訊對象:127.0.0.1。
* MV3 service worker: Chrome auto-terminates it after ~30s idle, so it never
* stays resident in memory by nature. The queue lives in on-disk
* chrome.storage.local (bounded), and is deleted once delivered to the local
* service. Only communication target: 127.0.0.1.
*/

const ENDPOINT = `http://127.0.0.1:${SHARED.serverPort}`;
Expand All @@ -15,7 +16,7 @@ interface Stats {
lastError: string | null;
}

// storage 讀改寫的簡易序列化,避免同 SW 實例內的競態
// Simple serialization of storage read-modify-write to avoid races within the same SW instance.
let chain: Promise<unknown> = Promise.resolve();
function serialize<T>(fn: () => Promise<T>): Promise<T> {
const p = chain.then(fn);
Expand All @@ -38,7 +39,7 @@ chrome.runtime.onMessage.addListener((msg: { event?: string }) => {
void serialize(async () => {
const queue = await getQueue();
queue.push(msg);
while (queue.length > MAX_QUEUE) queue.shift(); // 有界佇列:超過即丟最舊
while (queue.length > MAX_QUEUE) queue.shift(); // Bounded queue: drop the oldest when exceeded.
await chrome.storage.local.set({ queue });
}).then(() => flush());
});
Expand All @@ -61,7 +62,7 @@ function flush(): Promise<void> {
stats.lastFlushAt = Date.now();
stats.lastError = null;
} catch (e) {
// 本機服務沒開:留在磁碟佇列,等 alarm 重試
// Local service not running: leave it in the on-disk queue and retry on the next alarm.
stats.lastError = String(e);
}
await chrome.storage.local.set({ stats });
Expand Down
20 changes: 10 additions & 10 deletions extension/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { classifyUrl, type PageKind } from "../../src/classify/filter.js";
import { SHARED } from "../../src/shared/settings.js";

/**
* 主動閱讀追蹤器。記憶體設計原則:
* - 每個分頁只維持一組計數器;分頁不可見時計時器完全停止
* - 內文擷取只在跨過門檻那一刻執行一次,送出即丟,不在頁面端保留
* - 敏感頁與雜訊頁從一開始就不追蹤
* Active-reading tracker. Memory design principles:
* - Only one set of counters per tab; the timer fully stops when the tab is hidden.
* - Body extraction runs once at the moment the threshold is crossed, sent and discarded, never retained on the page.
* - Sensitive and noise pages are not tracked from the outset.
*/

const cfg = SHARED.capture;
Expand All @@ -33,7 +33,7 @@ function send(msg: unknown): void {
try {
void chrome.runtime.sendMessage(msg);
} catch {
// extension 重新載入後舊的 content script 會失去連線,靜默忽略
// After the extension reloads, the old content script loses its connection; ignore silently.
}
}

Expand Down Expand Up @@ -64,7 +64,7 @@ function stopTimer(): void {
}
}

// 只在分頁可見時跑 1 秒一次的計時器
// Run the 1-second-interval timer only while the tab is visible.
function syncTimer(): void {
if (!tracker) return;
const visible = document.visibilityState === "visible";
Expand Down Expand Up @@ -103,9 +103,9 @@ function extract(kind: PageKind): { title: string | null; excerpt: string | null
}
}
} catch {
// Readability 失敗時走 fallback
// Fall back when Readability fails.
}
// 社群貼文 / 非典型頁面:og meta +主要區塊文字(有上限)
// Social posts / atypical pages: og meta plus main-block text (capped).
const meta = (name: string) =>
document.querySelector(`meta[property="${name}"], meta[name="${name}"]`)?.getAttribute("content") ?? null;
const main = document.querySelector("article, main, [role='main']");
Expand Down Expand Up @@ -135,7 +135,7 @@ function capture(): void {
});
}

// 離開/切走時回報最終閱讀量(僅已擷取的頁面)
// On leaving/switching away, report the final read amount (only for already-captured pages).
function sendFinal(): void {
if (!tracker?.captured) return;
send({
Expand All @@ -158,7 +158,7 @@ document.addEventListener("visibilitychange", () => {
});
addEventListener("pagehide", sendFinal);

// SPA 導航(Threads/FB/新聞站都是 SPA):URL 變了就結算上一頁、追蹤新頁
// SPA navigation (Threads/FB/news sites are all SPAs): when the URL changes, finalize the previous page and track the new one.
setInterval(() => {
if (location.href !== currentUrl) {
sendFinal();
Expand Down
2 changes: 1 addition & 1 deletion scripts/ensure-config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// postinstall:首次安裝時建立個人設定檔(gitignored),讓 fresh clone 開箱即用
// postinstall: on first install, create the personal config file (gitignored) so a fresh clone works out of the box
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand Down
18 changes: 9 additions & 9 deletions scripts/heartbeat.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Claude CLI 憑證保鮮心跳:每天以一個極小的呼叫讓 OAuth refresh 週期保持活躍,
// 避免閒置一週後過期害出刊失敗;偵測到「曾經正常、現在失效」時用通知中心告警。
// Claude CLI credential-freshness heartbeat: a tiny daily call keeps the OAuth refresh cycle active,
// avoiding an idle-week expiry that would break publishing; alerts via Notification Center when it detects "was fine, now broken".
//
// 對非 CLI 用戶無害:沒安裝 claude 就靜默跳過;從未成功過(代表用戶走 API key
// 從不使用 CLI)也不告警——只有真正的憑證衰變才會吵你。
// Harmless for non-CLI users: silently skips if claude isn't installed; and if it never once succeeded (meaning the user is on an API key,
// never uses the CLI) it won't alert either — only genuine credential decay bothers you.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
Expand All @@ -12,13 +12,13 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
const okMarker = path.join(repoRoot, "data", "logs", ".heartbeat-was-ok");
const stamp = new Date().toString();

// 接收服務健康檢查:裝了常駐 serve agent 卻連不上 127.0.0.1:8787 → 擷取資料正在流失,當天告警。
// 沒裝 serve agent 的用戶不檢查(避免對只手動使用的人天天誤報)。
// Receiver health check: if the resident serve agent is installed but 127.0.0.1:8787 is unreachable → capture data is being lost; alert that day.
// Users without the serve agent aren't checked (avoids daily false alarms for manual-only users).
const servePlist = path.join(home(), "Library", "LaunchAgents", "com.browstack.serve.plist");
if (fs.existsSync(servePlist)) {
let serverOk = false;
try {
// 埠號與 src/shared/settings.ts 的 SHARED.serverPort 綁定(皆為 8787);若那裡改埠,這裡要一起改。
// The port is tied to SHARED.serverPort in src/shared/settings.ts (both 8787); if you change the port there, change it here too.
const res = await fetch("http://127.0.0.1:8787/health", { signal: AbortSignal.timeout(2000) });
serverOk = res.ok;
} catch {
Expand All @@ -41,7 +41,7 @@ function home() {
return process.env.HOME || "";
}

// 沒有 claude CLI(用戶走 Anthropic API)→ 無憑證可保鮮,靜默結束
// No claude CLI (user is on the Anthropic API) → no credentials to keep fresh, exit silently
const which = spawnSync("which", ["claude"], { encoding: "utf8" });
if (which.status !== 0) {
console.log(`[heartbeat] ${stamp} — 未安裝 claude CLI,略過`);
Expand Down Expand Up @@ -79,7 +79,7 @@ if (!failed) {
console.error(
`[heartbeat] ${stamp} — Claude CLI 憑證異常:${(result.stderr || result.stdout || "").slice(0, 160)}`,
);
// 只有「曾經成功過」才告警——從未成功代表用戶根本不用 CLI provider,不該吵他
// Only alert if it once succeeded — never having succeeded means the user doesn't use the CLI provider at all, so don't bother them
if (fs.existsSync(okMarker)) {
try {
spawnSync("osascript", [
Expand Down
24 changes: 12 additions & 12 deletions scripts/install-weekly.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 安裝 launchd 排程:每週自動出刊(macOS
// Install the launchd schedule: automatic weekly publishing (macOS)
// Usage: npm run schedule:weekly [-- --day 6 --hour 8 --minute 17]
// --day 0-6(0=週日…6=週六,預設 6)
// --day 0-6 (0=Sunday…6=Saturday, default 6)
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
Expand All @@ -24,13 +24,13 @@ const label = "com.browstack.weekly";
const logDir = path.join(repoRoot, "data", "logs");
fs.mkdirSync(logDir, { recursive: true });

// PATH 需含 node/npm claude CLIlaunchd 環境極簡);含 Apple Silicon /opt/homebrew
// PATH must include node/npm and the claude CLI (launchd's environment is minimal); includes Apple Silicon's /opt/homebrew
const PATH = `${nodeDir}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:${home}/.local/bin`;

// 前置檢查:better-sqlite3 的原生模組必須能在「即將被釘用的這個 node」下載入。
// 版本不符(例如從 Node 22 shell 執行,但模組是為 Node 20 建置)會讓常駐 server 靜默 crash-loop、
// 落地資料流失。與其之後才發現,不如現在就擋下並給出明確修法。
// 必須實際建構一個 DB——原生 .node 是在 new Database() 時才 dlopen,單純 require 不會觸發、會誤判為通過。
// Preflight check: better-sqlite3's native module must load under the exact node we're about to pin.
// A version mismatch (e.g. run from a Node 22 shell but the module was built for Node 20) makes the resident server
// silently crash-loop and lose landed data. Better to block it now with a clear fix than discover it later.
// Must actually construct a DB — the native .node is only dlopen'd at new Database(); a bare require won't trigger it and would falsely pass.
const probe = spawnSync(nodeBin, ["-e", "new (require('better-sqlite3'))(':memory:').close()"], {
cwd: repoRoot,
encoding: "utf8",
Expand All @@ -46,7 +46,7 @@ if (probe.status !== 0) {
process.exit(1);
}

// 出刊有兩個時段:主跑+ 12 小時後的當日重試(weekly.mjs 有冪等保護,成功後重試自動跳過)
// Publishing has two slots: the main run + a same-day retry 12 hours later (weekly.mjs is idempotent, so the retry auto-skips after success)
const retryHour = (hour + 12) % 24;

const agentPlist = (agentLabel, programArgs, scheduleXml, logFile) => `<?xml version="1.0" encoding="UTF-8"?>
Expand Down Expand Up @@ -85,16 +85,16 @@ const weeklyCalendar = `<key>StartCalendarInterval</key>
</dict>
</array>`;

// 心跳:每天一個極小的 claude 呼叫保鮮 CLI 憑證,失效時提前用通知中心告警
// Heartbeat: a tiny daily claude call keeps the CLI credentials fresh, alerting via Notification Center before they expire
const heartbeatLabel = "com.browstack.heartbeat";
const heartbeatCalendar = `<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>37</integer>
</dict>`;

// 閱讀訊號接收服務:extension 的落地端,常駐(登入即啟、當掉自動重啟)
// 只綁 127.0.0.1,記憶體佔用極小;不常駐的話 extension 的磁碟佇列(上限 300 筆)滿了會丟資料
// Reading-signal receiver service: the extension's landing endpoint, resident (starts at login, auto-restarts on crash)
// Binds 127.0.0.1 only, tiny memory footprint; without a resident service the extension's disk queue (max 300 entries) fills up and drops data
const serveLabel = "com.browstack.serve";
const serveSchedule = `<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>`;
Expand All @@ -106,7 +106,7 @@ fs.mkdirSync(laDir, { recursive: true });
function installAgent(agentLabel, xml) {
const plistPath = path.join(laDir, `${agentLabel}.plist`);
fs.writeFileSync(plistPath, xml);
spawnSync("launchctl", ["bootout", `gui/${uid}/${agentLabel}`], { stdio: "ignore" }); // 先卸舊版,失敗無妨
spawnSync("launchctl", ["bootout", `gui/${uid}/${agentLabel}`], { stdio: "ignore" }); // bootout the old version first; failure is fine
const boot = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { encoding: "utf8" });
if (boot.status !== 0) {
console.error(`launchctl bootstrap ${agentLabel} 失敗:${boot.stderr || boot.stdout}`);
Expand Down
24 changes: 12 additions & 12 deletions scripts/security-gates.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
#!/usr/bin/env bash
# 安全不變式 grep 閘——任一命中即失敗。與 SECURITY.md 的「Security invariants」對應。
# 開源專案的關鍵防迴歸:這些「看似無害」的改動,任一都可能危及每個安裝。
# 本地執行:npm run security-gates
# Security-invariant grep gates — any hit fails. Mirrors the "Security invariants" in SECURITY.md.
# Key regression guard for an open-source project: any of these "harmless-looking" changes could endanger every install.
# Run locally: npm run security-gates
set -uo pipefail
cd "$(dirname "$0")/.."

fail=0

# 命中 pattern 即失敗(用於「不該出現」的東西)。
# 忽略純註解行(//、*、#)——說明文字可以提到這些字面,只有實際程式碼才算違規。
# Fail on a pattern hit (for things that "should not appear").
# Ignore pure comment lines (//, *, #) — prose may mention these literals; only actual code counts as a violation.
deny() {
local desc="$1"; shift
local pattern="$1"; shift
Expand All @@ -23,17 +23,17 @@ deny() {
fi
}

# 綁定位址永遠本機迴環,絕不 0.0.0.0
# The bind address is always the local loopback, never 0.0.0.0
deny "server binds 127.0.0.1 only (no 0.0.0.0)" '0\.0\.0\.0' src/
# CSP:default-src 'none' 已封殺腳本,不得再出現 script-src / unsafe-eval
# CSP: default-src 'none' already blocks scripts; no script-src / unsafe-eval should appear
deny "CSP has no script-src directive" 'script-src' src/
deny "CSP has no unsafe-eval" 'unsafe-eval' src/
# jsdom 必須維持惰性預設(解析敵意 HTML;啟用腳本/資源=RCE/SSRF
# jsdom must stay inert by default (it parses hostile HTML; enabling scripts/resources = RCE/SSRF)
deny "jsdom stays inert (no runScripts / resources:usable)" "runScripts|resources:[[:space:]]*[\"']usable" src/
# server.tsHost rebinding 檢查必須精確比對,不得用寬鬆字串比對
# server.ts's Host anti-rebinding check must be an exact match, not a loose string comparison
deny "server.ts Host check is exact (no includes/startsWith/endsWith)" '\.(includes|startsWith|endsWith)\(' src/server.ts

# PR2 archiveToken.ts 若存在:token 比對必須用 timingSafeEqual、且無字面 default
# From PR2 on, if archiveToken.ts exists: the token comparison must use timingSafeEqual, with no literal default
if [ -f src/archiveToken.ts ]; then
if ! grep -q "timingSafeEqual" src/archiveToken.ts; then
echo "✗ GATE FAILED: archiveToken.ts must compare with crypto.timingSafeEqual"
Expand All @@ -44,7 +44,7 @@ if [ -f src/archiveToken.ts ]; then
deny "archive token has no hardcoded/default fallback" 'archive[_-]?token[^\n]*(\|\||\?\?)[[:space:]]*[\"'\''`]' src/
fi

# 個人資料檔絕不進版控
# Personal data files must never be committed to version control
tracked="$(git ls-files -- data/ out/ assets/covers/ src/shared/userConfig.ts 2>/dev/null || true)"
if [ -n "$tracked" ]; then
echo "✗ GATE FAILED: personal files are tracked:"
Expand All @@ -54,7 +54,7 @@ else
echo "✓ no personal data files tracked"
fi

# .gitignore 仍涵蓋所有敏感路徑
# .gitignore still covers all sensitive paths
for p in data/ out/ assets/covers/ src/shared/userConfig.ts .env; do
if git check-ignore -q "$p"; then
echo "✓ ignored: $p"
Expand Down
22 changes: 11 additions & 11 deletions scripts/weekly.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 每週出刊:ingest → enrich → cover → digest → send
// launchd 排程呼叫(npm run schedule:weekly 安裝,每週兩個時段:主跑+當日重試),
// 也可手動 npm run weekly
// Weekly publishing: ingest → enrich → cover → digest → send
// Invoked by the launchd schedule (installed via npm run schedule:weekly, two slots per week: main run + same-day retry),
// or run manually with npm run weekly.
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -9,19 +9,19 @@ import { createRequire } from "node:module";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const require = createRequire(import.meta.url);

// 失敗絕不無聲:macOS 通知中心告警
// Never fail silently: alert via macOS Notification Center
function notify(message) {
try {
spawnSync("osascript", [
"-e",
`display notification ${JSON.stringify(message)} with title "Browstack" sound name "Basso"`,
]);
} catch {
/* 通知失敗不影響流程 */
/* a failed notification doesn't affect the flow */
}
}

// 冪等保護:同一週已成功寄出 → 重試時段直接跳過,絕不重複寄送
// Idempotency guard: if this week already sent successfully → the retry slot skips outright, never sending twice
try {
const Database = require("better-sqlite3");
const db = new Database(path.join(repoRoot, "data", "browstack.db"), { readonly: true });
Expand All @@ -32,7 +32,7 @@ try {
process.exit(0);
}
} catch {
/* DB 尚不存在(全新安裝)→ 照常執行 */
/* DB doesn't exist yet (fresh install) → run as usual */
}

function run(script, { tolerate = false } = {}) {
Expand All @@ -53,12 +53,12 @@ function run(script, { tolerate = false } = {}) {

console.log(`[weekly] Browstack 出刊開始 / issue run started — ${new Date().toString()}`);
run("ingest");
// enrich 偶發失敗(LLM 逾時等)不殺整期:本週稍早已增潤的內容仍可出刊;
// 若最終完全沒有內容,email/send 會拒絕寄出空刊物(見 email.ts 保險)
// An occasional enrich failure (LLM timeout, etc.) doesn't kill the whole issue: content enriched earlier this week can still publish;
// if there's ultimately no content at all, email/send refuses to send an empty issue (see the safeguard in email.ts)
run("enrich", { tolerate: true });
// 封面渲染失敗(如金鑰未設)不擋出刊,沿用上一張封面
// A cover render failure (e.g. missing key) doesn't block publishing; reuse the previous cover
run("cover", { tolerate: true });
// 當週閱讀速寫(典藏櫥窗副標):LLM 產出,失敗不擋出刊,該期就沒有速寫副標
// The week's reading sketch (the collection-showcase subtitle): LLM-generated; a failure doesn't block publishing, the issue just has no sketch subtitle
run("digest", { tolerate: true });
run("send");
console.log(`[weekly] 出刊完成 / done — ${new Date().toString()}`);
Loading
Loading