Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ jobs:
if: steps.scope.outputs.mode == 'full'
- run: npm run smoke:ocr
if: steps.scope.outputs.mode == 'full'
- name: Real CoMER smoke
if: steps.scope.outputs.mode == 'full'
run: npx tsx scripts/ocr-real-smoke.ts
- run: npm run smoke:ai
if: steps.scope.outputs.mode == 'full'
- run: npm run test:pack
Expand Down
4 changes: 4 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ MathScrap
Copyright 2026 DNTOF

Licensed under the Apache License, Version 2.0.

The bundled handwritten-formula weights are third-party. See public/models/comer/NOTICE.md
for the CoMER (ECCV 2022) model and the ink-on Apache-2.0 distribution.
MathScrap does not relicense those files.
3 changes: 2 additions & 1 deletion crates/mathcore/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ name = "mathcore"
version = "0.0.1"
edition = "2021"
description = "Fast math helpers (parse, sample, solve, geometry inference) for the MathScrap kraft notebook, compiled to WASM."
license = "MIT"
# wasm-pack copies this field into src/wasm/pkg/package.json. Keep both Apache-2.0.
license = "Apache-2.0"

[lib]
crate-type = ["cdylib", "rlib"]
Expand Down
23 changes: 23 additions & 0 deletions docs/packaged-webview-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 打包后的 WebView

这一轮没有在打包好的窗口里跑过。Linux 上的 CI 只跑了 `test`。`web`、`android`、`windows` 在 pull request 上是跳过的,不是通过。

`src-tauri/tauri.conf.json` 里的 CSP 仍有 `https:`。打包后的页面直接 `fetch` 用户填写的 AI 地址,开发时代理不会打进安装包。`script-src` 保留 `wasm-unsafe-eval`,这是本机 ONNX WASM 要编译所需要的。`'unsafe-eval'` 已经去掉。

在 Windows 或 Android 上核对时,用一次本地未签名构建,不要改已经发布的 v0.0.1:

1. 打开应用,写一个短公式,点「识别」或按 R。公式模型仍应能给出候选。这只说明 ONNX 还在工作。
2. 在设置里填写一个你自己的 HTTPS 模型地址并测试连接。记下是连上了,还是控制台出现 CSP 拒绝。
3. 不要把自签证书装进受信任的根,也不要关闭系统的签名检查。

# Packaged WebView

This change did not run inside a packaged window. Linux CI ran `test` only. On a pull request, `web`, `android`, and `windows` are skipped. A skip is not a pass.

The CSP in `src-tauri/tauri.conf.json` still contains `https:`. The packaged page calls `fetch` on the AI URL the user typed. The dev proxy is not in the installed app. `script-src` keeps `wasm-unsafe-eval` so the local ONNX WASM can compile. `'unsafe-eval'` is already gone.

To check on Windows or Android, use one local unsigned build. Do not change the published v0.0.1:

1. Open the app, write a short formula, and press Recognize or R. A candidate means ONNX still runs. It is not a handwriting score.
2. In settings, enter an HTTPS model address you control and test the connection. Record whether it connects or the console shows a CSP refusal.
3. Do not install the self-signed certificate as a trusted root, and do not turn off signature checks.
39 changes: 39 additions & 0 deletions docs/release-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 安装后核对

这些说明只在源码分支里。已经发布的 v0.0.1 页面没有改。

手写识别只在你点「识别」或按 R 时运行。先看笔迹是不是一行字;一行字不会被送进公式模型。公式模型是本地的 CoMER INT8。自动测试用的是程序画出的折线,不是扫描的手写。空白和很小的噪点不会出字。这次实测的合成结果是:`y = 2`(0.97)、`Y = x`(0.75,模型把 y 读成大写)、`(1)/(2)`(0.97)、`a^2 = b`(0.97)、`\sum`(0.84)、`sigma`(0.78)、最高 `A(2, 2)`(0.95),第二名 `A(2, 2, 2)`(0.76)仍是已知的多余坐标。写入线是 0.62。没有附带可再分发的真人笔迹样本,所以不能把这些结果说成手写准确率。门先看笔迹结构,不认识中文或英文。一行或两行普通笔迹不会送进公式模型。有等号、分式、上标、括号、求和、sigma 或箭头的才是公式。数字是模型置信度,不是准确率。

开发时的桥需要 HttpOnly 会话 cookie。伪造 Origin 或 Sec-Fetch-Site 拿不到它。浏览器在打开页面时拿到 cookie。stdio 客户端读一个 0600 文件,不会把内容打印出来。局域网要另设一串足够长的分享口令,并 POST 到 `/__mathscrap/join` 才换到 cookie。太短或占位口令不会打开分享。同一台机器上伪造一次“打开页面”的请求仍可能拿到 cookie,同一用户也能读那个文件。模型文件的 SHA-256 记在 `scripts/ocr-model-pins.json`。缺文件或哈希不对,测试失败,不会算通过。

Windows 安装包是本机自签,时间戳是 DigiCert RFC3161。系统会显示未知发布者。不要把这个证书装进受信任的根,也不要关掉系统的签名检查。Android 包是 release,arm64,不是调试包。

核对已发布文件时,在你自己的电脑上计算 SHA-256,和发布页上的 `SHA256SUMS` 比较:

```
MathScrap-0.0.1-windows-x64-setup.exe
MathScrap-0.0.1-windows-x64.exe
MathScrap-0.0.1-android-arm64-v8a.apk
```

Git 标签 `v0.0.1` 的签名可以在本地用随包的公钥检查。签名 UID 是 `DNT_OF`,UID 上没有邮箱。标签对象里的 tagger 邮箱是 `3174600270@qq.com`。这是两个不同的字段。签名有效不等于 GitHub 上显示 Verified。

# After you install

These notes live in the source branch. The published v0.0.1 page was not changed.

Handwriting recognition runs when you press Recognize or R. A line of writing is not sent to the formula model. The formula model is local CoMER INT8. The automated cases use strokes drawn by the program, not scanned handwriting. Blank ink and tiny noise produce no text. The synthetic readings this run actually produced are `y = 2` (0.97), `Y = x` (0.75; the model capitalized y), `(1)/(2)` (0.97), `a^2 = b` (0.97), `\sum` (0.84), `sigma` (0.78), and top `A(2, 2)` (0.95). A weaker second candidate `A(2, 2, 2)` (0.76) is still the known extra-coordinate miss. The write line is 0.62. There is no redistributable sample of a real person's handwriting here, so these results are not a handwriting accuracy claim. Model SHA-256 values are in `scripts/ocr-model-pins.json`. A missing file or a different hash fails the test.

The Windows installer is self-signed, with a DigiCert RFC3161 timestamp. The system shows an unknown publisher. Do not install that certificate as a trusted root, and do not turn off signature checks. The Android package is a release build for arm64, not a debug build.

To check the published files, hash them on your own computer and compare with `SHA256SUMS` on the release page:

```
MathScrap-0.0.1-windows-x64-setup.exe
MathScrap-0.0.1-windows-x64.exe
MathScrap-0.0.1-android-arm64-v8a.apk
```

The `v0.0.1` tag signature can be checked locally with the published public key. The signing UID is `DNT_OF` and that UID has no email. The tagger email stored on the tag object is `3174600270@qq.com`. Those are different fields. A valid signature is not the same as a GitHub Verified badge.

The packaged WebView still allows `https:` in `connect-src`, because a configured AI endpoint is fetched by the page itself. `script-src` keeps `wasm-unsafe-eval` for ONNX and drops `'unsafe-eval'`. That CSP line has not been executed inside a packaged WebView in this change, and CORS for a packaged build has not been run. The dev bridge requires an HttpOnly session cookie. Forged `Origin` and `Sec-Fetch-Site` headers do not grant it. The browser receives the cookie on a document load. The stdio client reads a mode 0600 file and does not print it. A LAN peer gets a cookie only by posting the operator's share token to `/__mathscrap/join`; a short or placeholder token does not turn sharing on. A process on the same machine that forges a document navigation can still receive the cookie, and a same-user process can read the session file.
116 changes: 99 additions & 17 deletions mcp/ai-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { t } from '../src/i18n/locale.ts'
import http from 'node:http'
import https from 'node:https'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { readBody, sendJson } from './http.ts'
import { isBlockedHostname, localOriginsFromEnv, resolveAndCheck, sameAddressSet, type LookupFn } from './ssrf.ts'
import { peerAllowed } from './peer.ts'

export const PROXY_HEADER = 'x-mathscrap-proxy'

const ALLOWED_HEADERS = new Set(['authorization', 'content-type', 'accept', 'x-api-key', 'anthropic-version', 'anthropic-dangerous-direct-browser-access'])

export type ForwardDeps = {
lookup?: LookupFn
localOrigins?: URL[]
}

/**
* Forwards one provider request from the page. Dev-only.
* Never log the body or headers — they may contain the API key.
* Only chat/models/messages paths are allowed, and link-local metadata hosts are refused.
*/
export async function forwardAi(payload: { url?: unknown; method?: unknown; headers?: unknown; body?: unknown }, fetchImpl: typeof fetch = fetch, signal?: AbortSignal) {
export async function forwardAi(
payload: { url?: unknown; method?: unknown; headers?: unknown; body?: unknown },
fetchImpl?: typeof fetch,
signal?: AbortSignal,
deps: ForwardDeps = {},
) {
const method = typeof payload.method === 'string' ? payload.method.toUpperCase() : 'GET'
if (method !== 'GET' && method !== 'POST') throw new Error(t('proxyMethod'))
if (typeof payload.url !== 'string' || !payload.url) throw new Error(t('proxyTarget'))
Expand All @@ -23,8 +37,14 @@ export async function forwardAi(payload: { url?: unknown; method?: unknown; head
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') throw new Error(t('proxyHttp'))
if (target.username || target.password) throw new Error(t('proxyUserinfo'))
if (isBlockedHost(target.hostname)) throw new Error(t('proxyBlocked'))
if (!allowedPath(target.pathname)) throw new Error(t('proxyPath'))
const origins = deps.localOrigins ?? localOriginsFromEnv()
let addresses
try {
addresses = await resolveAndCheck(target, deps.lookup, origins)
} catch {
throw new Error(t('proxyBlocked'))
}
const headers: Record<string, string> = { accept: 'application/json' }
if (payload.headers && typeof payload.headers === 'object') {
for (const [key, value] of Object.entries(payload.headers as Record<string, unknown>)) {
Expand All @@ -33,16 +53,20 @@ export async function forwardAi(payload: { url?: unknown; method?: unknown; head
headers[key] = value
}
}
const timeout = AbortSignal.timeout(25_000)
const upstream = await fetchImpl(target, {
method,
headers,
body: method === 'POST' ? JSON.stringify(payload.body ?? {}) : undefined,
redirect: 'error',
signal: signal ? AbortSignal.any([timeout, signal]) : timeout,
})
const text = (await upstream.text()).slice(0, 200_000)
return { status: upstream.status, text, contentType: upstream.headers.get('content-type') ?? 'application/json; charset=utf-8' }
if (fetchImpl) {
const timeout = AbortSignal.timeout(25_000)
const upstream = await fetchImpl(target, {
method,
headers,
body: method === 'POST' ? JSON.stringify(payload.body ?? {}) : undefined,
redirect: 'error',
signal: signal ? AbortSignal.any([timeout, signal]) : timeout,
})
if (upstream.status >= 300 && upstream.status < 400) throw new Error(t('proxyBlocked'))
const text = (await upstream.text()).slice(0, 200_000)
return { status: upstream.status, text, contentType: upstream.headers.get('content-type') ?? 'application/json; charset=utf-8' }
}
return requestPinned(target, method, headers, method === 'POST' ? JSON.stringify(payload.body ?? {}) : undefined, addresses, signal, deps)
}

export async function handleAiProxy(req: IncomingMessage, res: ServerResponse, fetchImpl?: typeof fetch) {
Expand All @@ -51,6 +75,10 @@ export async function handleAiProxy(req: IncomingMessage, res: ServerResponse, f
sendJson(res, 404, { error: '未知路径' }, { [PROXY_HEADER]: '1' })
return
}
if (!peerAllowed(req, true)) {
sendJson(res, 403, { error: t('proxyBlocked') }, { [PROXY_HEADER]: '1' })
return
}
try {
const payload = JSON.parse(await readBody(req, 1_000_000)) as { url?: unknown; method?: unknown; headers?: unknown; body?: unknown }
const ac = new AbortController()
Expand All @@ -74,9 +102,63 @@ export function allowedPath(pathname: string) {
}

export function isBlockedHost(hostname: string) {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
if (host === 'metadata.google.internal' || host === 'metadata.google.com') return true
if (host.endsWith('.internal') && host.includes('metadata')) return true
const v4 = host.startsWith('::ffff:') ? host.slice(7) : host
return /^169\.254\./.test(v4)
return isBlockedHostname(hostname, false)
}

function requestPinned(
target: URL,
method: string,
headers: Record<string, string>,
body: string | undefined,
addresses: { address: string; family: number }[],
signal?: AbortSignal,
deps: ForwardDeps = {},
) {
const lib = target.protocol === 'https:' ? https : http
const pinned = addresses[0]
return new Promise<{ status: number; text: string; contentType: string }>((resolve, reject) => {
const req = lib.request(
{
protocol: target.protocol,
hostname: pinned.address,
port: target.port || (target.protocol === 'https:' ? 443 : 80),
path: `${target.pathname}${target.search}`,
method,
headers: { ...headers, host: target.host },
servername: target.hostname,
lookup: (_hostname, _options, callback) => {
resolveAndCheck(target, deps.lookup, deps.localOrigins ?? localOriginsFromEnv())
.then((again) => {
if (!sameAddressSet(again, addresses)) {
callback(new Error('blocked'), '', 4)
return
}
callback(null, pinned.address, pinned.family)
})
.catch((error: Error) => callback(error, '', 4))
},
},
(res) => {
if ((res.statusCode ?? 0) >= 300 && (res.statusCode ?? 0) < 400) {
res.resume()
reject(new Error(t('proxyBlocked')))
return
}
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
resolve({
status: res.statusCode ?? 502,
text: Buffer.concat(chunks).subarray(0, 200_000).toString('utf8'),
contentType: String(res.headers['content-type'] ?? 'application/json; charset=utf-8'),
})
})
},
)
const fail = (error: Error) => reject(error.message === 'blocked' ? new Error(t('proxyBlocked')) : error)
req.on('error', fail)
signal?.addEventListener('abort', () => req.destroy(new Error('aborted')), { once: true })
if (body) req.write(body)
req.end()
})
}
6 changes: 6 additions & 0 deletions mcp/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { t } from '../src/i18n/locale.ts'
import { randomUUID } from 'node:crypto'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { readBody, sendEmpty, sendJson } from './http.ts'
import { peerAllowed } from './peer.ts'

export const BRIDGE_HEADER = 'x-mathscrap-bridge'
const MARK = { [BRIDGE_HEADER]: '1' }
Expand Down Expand Up @@ -59,6 +60,11 @@ export function createBridge() {
async function handle(req: IncomingMessage, res: ServerResponse) {
const path = (req.url ?? '').split('?')[0] ?? ''
if (!path.startsWith('/__mathscrap/bridge')) return false
const browserRoute = path === '/__mathscrap/bridge/wait' || path === '/__mathscrap/bridge/result' || path === '/__mathscrap/bridge/health'
if (!peerAllowed(req, browserRoute)) {
sendJson(res, 403, { ok: false, error: 'refused' }, MARK)
return true
}
try {
if (req.method === 'GET' && path === '/__mathscrap/bridge/health') {
sendJson(res, 200, { ok: true, browser: browserLive() }, MARK)
Expand Down
Loading
Loading