feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,229 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
const issueMarkdown = `- 代入数据:$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$,$m=1.1\\ \\text{kg}$,$\\Delta t=40℃$
- 计算得:$Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}$
- 第(1)问:学生遗漏了比热容的数量级$10^3$,将$0.75\\times10^3\\ \\text{J/(kg·℃)}$错写为$0.75\\ \\text{J/(kg·℃)}$,导致计算出的$Q_1$错误。`
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4291, end = 4330) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logs = []
const child = spawn(
'pnpm',
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CI: '1',
},
},
)
child.stdout.on('data', chunk => logs.push(String(chunk)))
child.stderr.on('data', chunk => logs.push(String(chunk)))
return {
child,
getLogs() {
return logs.join('')
},
}
}
function createIssueUrl(port) {
return `http://${host}:${port}/test#data=raw:${encodeURIComponent(issueMarkdown)}`
}
async function waitForIssueMath(page) {
await page.locator('.preview-surface').waitFor({ state: 'visible', timeout: 30000 })
await page.waitForFunction(() => {
const root = document.querySelector('.preview-surface')
if (!root)
return false
const inlineNodes = Array.from(root.querySelectorAll('[data-markstream-math="inline"]'))
if (inlineNodes.length !== 8)
return false
return inlineNodes.every((node) => {
const mode = node.getAttribute('data-markstream-mode')
return mode === 'katex'
&& Boolean(node.querySelector('.katex'))
&& !node.querySelector('.math-inline--fallback')
&& !node.querySelector('.math-inline__loading')
})
}, { timeout: 30000 })
}
async function readSummary(page) {
return page.evaluate(() => {
const root = document.querySelector('.preview-surface')
const inlineNodes = Array.from(root?.querySelectorAll('[data-markstream-math="inline"]') || [])
return {
inlineCount: inlineNodes.length,
inlineModes: inlineNodes.map(node => node.getAttribute('data-markstream-mode')),
katexCount: root?.querySelectorAll('[data-markstream-math="inline"] .katex').length || 0,
fallbackInlineCount: root?.querySelectorAll('.math-inline--fallback').length || 0,
fallbackBlockCount: root?.querySelectorAll('.math-block__fallback').length || 0,
loadingInlineCount: root?.querySelectorAll('.math-inline__loading').length || 0,
katexErrorCount: root?.querySelectorAll('.katex-error').length || 0,
previewText: root?.textContent || '',
}
})
}
async function main() {
const port = await findFreePort()
const server = startDevServer(port)
let browser
let page
try {
await waitForPort(port)
browser = await chromium.launch(resolveChromeLaunchOptions())
page = await browser.newPage({ viewport: { width: 1440, height: 1200 } })
const relevantConsoleIssues = []
const pageErrors = []
page.on('console', (msg) => {
if ((msg.type() === 'error' || msg.type() === 'warning') && /katex|cdotp|character metrics|undefined control sequence/i.test(msg.text()))
relevantConsoleIssues.push(`[${msg.type()}] ${msg.text()}`)
})
page.on('pageerror', error => pageErrors.push(String(error?.message || error)))
await page.goto(createIssueUrl(port), { waitUntil: 'load' })
await waitForIssueMath(page)
const summary = await readSummary(page)
if (summary.inlineCount !== 8)
throw new Error(`Expected 8 inline math nodes, got ${summary.inlineCount}`)
if (summary.katexCount !== 8)
throw new Error(`Expected 8 rendered KaTeX nodes, got ${summary.katexCount}`)
if (summary.inlineModes.some(mode => mode !== 'katex'))
throw new Error(`Expected all inline math nodes to be in katex mode, got ${summary.inlineModes.join(', ')}`)
if (summary.fallbackInlineCount !== 0 || summary.fallbackBlockCount !== 0)
throw new Error(`Unexpected fallback nodes: inline=${summary.fallbackInlineCount}, block=${summary.fallbackBlockCount}`)
if (summary.loadingInlineCount !== 0)
throw new Error(`Unexpected loading math nodes left in preview: ${summary.loadingInlineCount}`)
if (summary.katexErrorCount !== 0)
throw new Error(`Unexpected KaTeX error nodes in preview: ${summary.katexErrorCount}`)
if (summary.previewText.includes('$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$'))
throw new Error('Preview still contains raw fallback text for the first issue formula')
if (relevantConsoleIssues.length)
throw new Error(`Unexpected KaTeX console issues:\n${relevantConsoleIssues.join('\n')}`)
if (pageErrors.length)
throw new Error(`Unexpected page errors:\n${pageErrors.join('\n')}`)
console.log('KaTeX unicode-unit e2e passed.')
console.log(JSON.stringify(summary, null, 2))
}
catch (error) {
if (page) {
try {
const screenshot = '/tmp/markstream-katex-unicode-unit-regression.png'
await page.screenshot({ path: screenshot, fullPage: true })
console.error(`Saved failure screenshot to ${screenshot}`)
}
catch {}
}
console.error(server.getLogs())
throw error
}
finally {
await browser?.close().catch(() => {})
killProcessTree(server.child)
}
}
main().catch((error) => {
console.error('[e2e-katex-unicode-unit-regression] failed')
console.error(error)
process.exitCode = 1
})