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:
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env node
|
||||
// Capture a deterministic screenshot of the diff UX demo for visual review.
|
||||
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
|
||||
|
||||
function isPortOpen(port, host = '127.0.0.1') {
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.createConnection({ host, port })
|
||||
sock.on('connect', () => {
|
||||
sock.end()
|
||||
resolve(true)
|
||||
})
|
||||
sock.on('error', () => {
|
||||
sock.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function findFreePort(start = 5173, end = 5190) {
|
||||
for (let port = start; port <= end; port++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const open = await isPortOpen(port)
|
||||
if (!open) return port
|
||||
}
|
||||
throw new Error(`No free port found in ${start}-${end}`)
|
||||
}
|
||||
|
||||
async function waitForPort(port, ms = 20000) {
|
||||
const start = Date.now()
|
||||
while (true) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const open = await isPortOpen(port)
|
||||
if (open) return
|
||||
if (Date.now() - start > ms)
|
||||
throw new Error(`Timed out waiting for port ${port}`)
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
}
|
||||
}
|
||||
|
||||
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 round(value) {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
function parseScenario(value) {
|
||||
if (value === 'streaming') return 'streaming'
|
||||
if (value === 'line-info-reference' || value === 'line-info')
|
||||
return 'line-info-reference'
|
||||
return 'pierre-reference'
|
||||
}
|
||||
|
||||
function captureSelectorForScenario(scenario) {
|
||||
return scenario === 'line-info-reference'
|
||||
? '.line-info-compare-frame'
|
||||
: '.editor-card'
|
||||
}
|
||||
|
||||
function viewportForScenario(scenario) {
|
||||
return scenario === 'line-info-reference'
|
||||
? { width: 1960, height: 1600 }
|
||||
: { width: 1960, height: 720 }
|
||||
}
|
||||
|
||||
function parseUnchangedStyle(value) {
|
||||
if (value === 'line-info-basic') return 'line-info-basic'
|
||||
if (value === 'simple') return 'simple'
|
||||
return value === 'metadata' ? 'metadata' : 'line-info'
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const styleArg = process.argv[2] === 'bar' ? 'bar' : 'background'
|
||||
const outputArg = process.argv[3]
|
||||
const scenarioArg = parseScenario(process.argv[4])
|
||||
const themeArg = process.argv[5] || 'snazzy-light'
|
||||
const linesArg = process.argv[6] || '6'
|
||||
const unchangedStyleArg = parseUnchangedStyle(process.argv[7])
|
||||
const port = process.env.PORT
|
||||
? Number(process.env.PORT)
|
||||
: await findFreePort()
|
||||
const outputPath = outputArg
|
||||
? path.resolve(outputArg)
|
||||
: path.join(
|
||||
'/tmp',
|
||||
`stream-monaco-diff-ux-${scenarioArg}-${themeArg}-${styleArg}.png`,
|
||||
)
|
||||
|
||||
const vite = spawn(
|
||||
'pnpm',
|
||||
[
|
||||
'-C',
|
||||
demoDir,
|
||||
'dev',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(port),
|
||||
'--strictPort',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
|
||||
)
|
||||
|
||||
const logs = []
|
||||
vite.stdout.on('data', (d) => logs.push(String(d)))
|
||||
vite.stderr.on('data', (d) => logs.push(String(d)))
|
||||
|
||||
const onExit = () => killProcessTree(vite)
|
||||
process.on('SIGINT', onExit)
|
||||
process.on('SIGTERM', onExit)
|
||||
process.on('exit', onExit)
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({
|
||||
viewport: viewportForScenario(scenarioArg),
|
||||
deviceScaleFactor: 2,
|
||||
})
|
||||
const url = `http://127.0.0.1:${port}/diff-ux?style=${styleArg}&scenario=${scenarioArg}&theme=${encodeURIComponent(
|
||||
themeArg,
|
||||
)}&lines=${encodeURIComponent(
|
||||
linesArg,
|
||||
)}&unchangedStyle=${encodeURIComponent(unchangedStyleArg)}&capture=1`
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
const captureSelector = captureSelectorForScenario(scenarioArg)
|
||||
await page.waitForSelector(captureSelector, { timeout: 20000 })
|
||||
await page.waitForTimeout(1400)
|
||||
|
||||
const target = page.locator(captureSelector)
|
||||
await target.screenshot({ path: outputPath })
|
||||
|
||||
const stats = await page.evaluate(() => {
|
||||
const getTextRect = (el) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
const rect = range.getBoundingClientRect()
|
||||
return { left: rect.left, right: rect.right, width: rect.width }
|
||||
}
|
||||
|
||||
const firstVisibleTokenRect = (line) => {
|
||||
const spans = Array.from(line.querySelectorAll('span'))
|
||||
for (const span of spans) {
|
||||
const text = span.textContent || ''
|
||||
if (!text.trim()) continue
|
||||
const rect = getTextRect(span)
|
||||
if (rect.width > 0) return rect
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const root = document.querySelector('.editor')
|
||||
const markerWidth =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(root ?? document.body).getPropertyValue(
|
||||
'--stream-monaco-gutter-marker-width',
|
||||
),
|
||||
) || 4
|
||||
|
||||
const getPaneMetrics = (rootSelector) => {
|
||||
const pane = document.querySelector(rootSelector)
|
||||
const margin = pane?.querySelector('.margin')
|
||||
const lineNumber = pane?.querySelector('.line-numbers')
|
||||
const viewLine = pane?.querySelector('.view-lines .view-line')
|
||||
if (!margin || !lineNumber || !viewLine) return null
|
||||
|
||||
const marginRect = margin.getBoundingClientRect()
|
||||
const lineNumberBoxRect = lineNumber.getBoundingClientRect()
|
||||
const lineNumberTextRect = getTextRect(lineNumber)
|
||||
const tokenRect = firstVisibleTokenRect(viewLine)
|
||||
if (!tokenRect) return null
|
||||
|
||||
return {
|
||||
marginWidth: marginRect.width,
|
||||
numberBoxWidth: lineNumberBoxRect.width,
|
||||
visibleLineNumber: lineNumber.textContent?.trim() ?? '',
|
||||
boxLeftGap: lineNumberBoxRect.left - marginRect.left,
|
||||
boxRightGap: tokenRect.left - lineNumberBoxRect.right,
|
||||
textStripeGap:
|
||||
lineNumberTextRect.left - marginRect.left - markerWidth,
|
||||
textCodeGap: tokenRect.left - lineNumberTextRect.right,
|
||||
}
|
||||
}
|
||||
|
||||
const values = Array.from(
|
||||
document.querySelectorAll('.file-stats .delta'),
|
||||
).map((node) => node.textContent?.trim() ?? '')
|
||||
return {
|
||||
deltas: values,
|
||||
title: document.querySelector('.file-name')?.textContent?.trim() ?? '',
|
||||
gutter: {
|
||||
original: getPaneMetrics('.editor.original'),
|
||||
modified: getPaneMetrics('.editor.modified'),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
style: styleArg,
|
||||
scenario: scenarioArg,
|
||||
theme: themeArg,
|
||||
lines: linesArg,
|
||||
unchangedStyle: unchangedStyleArg,
|
||||
captureSelector,
|
||||
outputPath,
|
||||
url,
|
||||
stats: {
|
||||
...stats,
|
||||
gutter:
|
||||
stats.gutter?.original && stats.gutter?.modified
|
||||
? {
|
||||
original: {
|
||||
marginWidth: round(stats.gutter.original.marginWidth),
|
||||
numberBoxWidth: round(
|
||||
stats.gutter.original.numberBoxWidth,
|
||||
),
|
||||
visibleLineNumber:
|
||||
stats.gutter.original.visibleLineNumber,
|
||||
boxLeftGap: round(stats.gutter.original.boxLeftGap),
|
||||
boxRightGap: round(stats.gutter.original.boxRightGap),
|
||||
textStripeGap: round(stats.gutter.original.textStripeGap),
|
||||
textCodeGap: round(stats.gutter.original.textCodeGap),
|
||||
symmetryDelta: round(
|
||||
Math.abs(
|
||||
stats.gutter.original.textStripeGap -
|
||||
stats.gutter.original.textCodeGap,
|
||||
),
|
||||
),
|
||||
},
|
||||
modified: {
|
||||
marginWidth: round(stats.gutter.modified.marginWidth),
|
||||
numberBoxWidth: round(
|
||||
stats.gutter.modified.numberBoxWidth,
|
||||
),
|
||||
visibleLineNumber:
|
||||
stats.gutter.modified.visibleLineNumber,
|
||||
boxLeftGap: round(stats.gutter.modified.boxLeftGap),
|
||||
boxRightGap: round(stats.gutter.modified.boxRightGap),
|
||||
textStripeGap: round(stats.gutter.modified.textStripeGap),
|
||||
textCodeGap: round(stats.gutter.modified.textCodeGap),
|
||||
symmetryDelta: round(
|
||||
Math.abs(
|
||||
stats.gutter.modified.textStripeGap -
|
||||
stats.gutter.modified.textCodeGap,
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Diff UX screenshot capture failed.')
|
||||
console.error(logs.slice(-40).join(''))
|
||||
throw error
|
||||
} finally {
|
||||
killProcessTree(vite)
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user