1
0
forked from boos/BoosAPI

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,761 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostListener,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getMermaid } from '../../optional/mermaid'
import { toSafeSvgMarkup } from '../../sanitizeSvg'
import { canParseOffthread, findPrefixOffthread } from '../../workers/mermaidWorkerClient'
import { clampPreviewHeight, estimateMermaidPreviewHeight, parsePositiveNumber } from '../shared/diagram-height'
import { getString } from '../shared/node-helpers'
import {
clampNumber,
copyTextToClipboard,
downloadSvgMarkup,
resolveCssSize,
setElementHtml,
} from '../shared/rich-block-helpers'
let mermaidRenderSequence = 0
let mermaidRenderQueue: Promise<void> = Promise.resolve()
type MermaidTheme = 'light' | 'dark'
function enqueueMermaidRender<T>(run: () => Promise<T>) {
const next = mermaidRenderQueue.then(run, run)
mermaidRenderQueue = next.then(() => undefined, () => undefined)
return next
}
@Component({
selector: 'markstream-angular-mermaid-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div
class="mermaid-block"
[class.dark]="resolvedIsDark"
[attr.data-markstream-mermaid]="'1'"
[attr.data-mode]="showSource ? 'source' : 'preview'"
>
<div *ngIf="resolvedShowHeader" class="mermaid-header">
<div class="flex items-center gap-2 min-w-0">
<span class="markstream-angular-enhanced-block__badge">Mermaid</span>
<span class="mermaid-title__text truncate">Diagram</span>
</div>
<div class="mermaid-actions">
<div *ngIf="resolvedShowModeToggle" class="mermaid-toggle" role="tablist" aria-label="Mermaid mode">
<button
type="button"
class="mermaid-toggle-btn"
[class.mermaid-toggle-btn--active]="!showSource"
[attr.aria-pressed]="!showSource"
(click)="setMode(false)"
>
Preview
</button>
<button
type="button"
class="mermaid-toggle-btn"
[class.mermaid-toggle-btn--active]="showSource"
[attr.aria-pressed]="showSource"
(click)="setMode(true)"
>
Source
</button>
</div>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="mermaid-btn"
[attr.title]="tooltipFor(copied ? 'Copied' : 'Copy')"
(click)="copySource()"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
*ngIf="resolvedShowExportButton"
type="button"
class="mermaid-btn"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Export SVG')"
(click)="exportSvg()"
>
Export
</button>
<button
*ngIf="resolvedShowFullscreenButton"
type="button"
class="mermaid-btn"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Fullscreen')"
(click)="openModal()"
>
Fullscreen
</button>
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="mermaid-btn"
[attr.title]="tooltipFor(collapsed ? 'Expand' : 'Collapse')"
(click)="toggleCollapsed()"
>
{{ collapsed ? 'Expand' : 'Collapse' }}
</button>
<ng-container *ngIf="resolvedShowZoomControls && !showSource && !collapsed">
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Zoom out')" (click)="adjustZoom(-0.1)">-</button>
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Reset zoom')" (click)="resetZoom()">100%</button>
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Zoom in')" (click)="adjustZoom(0.1)">+</button>
</ng-container>
</div>
</div>
<div *ngIf="!collapsed" class="mermaid-body">
<div *ngIf="rendering && !showSource" class="mermaid-loading">
<span class="mermaid-spinner" aria-hidden="true"></span>
<span>Rendering Mermaid...</span>
</div>
<div
*ngIf="!showSource"
class="mermaid-preview"
[style.maxHeight]="resolvedMaxHeight"
[style.minHeight.px]="estimatedPreviewHeightPx"
[style.height.px]="estimatedPreviewHeightPx"
[style.transform]="previewTransform"
>
<div #previewHost class="markstream-angular-mermaid" [class.is-empty]="!svgMarkup"></div>
</div>
<pre *ngIf="showSource" class="mermaid-source"><code translate="no">{{ code }}</code></pre>
<div *ngIf="error" class="mermaid-error">{{ error }}</div>
</div>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? 'Copied' : '' }}</span>
</div>
<div
*ngIf="modalOpen"
class="mermaid-modal-overlay"
role="dialog"
aria-modal="true"
(click)="closeModal()"
>
<div
class="mermaid-modal-panel"
[class.is-dark]="resolvedIsDark"
(click)="$event.stopPropagation()"
>
<div class="mermaid-modal-header">
<span class="mermaid-modal-title">Mermaid Preview</span>
<button type="button" class="mermaid-modal-close" (click)="closeModal()">Close</button>
</div>
<div class="mermaid-modal-body">
<div
class="mermaid-modal-content"
[style.transform]="modalTransform"
(wheel)="handleModalWheel($event)"
>
<div #modalHost class="markstream-angular-mermaid fullscreen"></div>
</div>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MermaidBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('previewHost') private previewHost?: ElementRef<HTMLElement>
@ViewChild('modalHost') private modalHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
rendering = false
copied = false
collapsed = false
showSource = false
modalOpen = false
zoom = 1
svgMarkup = ''
error = ''
private viewReady = false
private destroyed = false
private renderToken = 0
private copyTimer: number | null = null
get mergedProps() {
return {
...(this.context?.mermaidProps || {}),
...(this.props || {}),
}
}
get code() {
return getString((this.node as any)?.code)
.replace(/\]::([^:])/g, ']:::$1')
.replace(/:::subgraphNode$/gm, '::subgraphNode')
}
get resolvedIsDark() {
if (typeof this.mergedProps.isDark === 'boolean')
return this.mergedProps.isDark
return this.context?.isDark === true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowModeToggle() {
return this.mergedProps.showModeToggle !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowExportButton() {
return this.mergedProps.showExportButton !== false
}
get resolvedShowFullscreenButton() {
return this.mergedProps.showFullscreenButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowZoomControls() {
return this.mergedProps.showZoomControls !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedMaxHeight() {
return resolveCssSize(this.mergedProps.maxHeight, '500px')
}
get estimatedPreviewHeightPx() {
return clampPreviewHeight(
parsePositiveNumber(this.mergedProps.estimatedPreviewHeightPx) ?? estimateMermaidPreviewHeight(this.code),
undefined,
this.maxPreviewHeight,
)
}
private get maxPreviewHeight() {
if (this.mergedProps.maxHeight == null || this.mergedProps.maxHeight === 'none')
return this.mergedProps.maxHeight === 'none' ? null : 500
return parsePositiveNumber(this.mergedProps.maxHeight) ?? 500
}
get resolvedWorkerTimeout() {
return clampNumber(Number(this.mergedProps.workerTimeoutMs ?? 1400), 200, 10_000)
}
get resolvedParseTimeout() {
return clampNumber(Number(this.mergedProps.parseTimeoutMs ?? 1800), 200, 10_000)
}
get resolvedRenderTimeout() {
return clampNumber(Number(this.mergedProps.renderTimeoutMs ?? 2500), 200, 15_000)
}
get resolvedFullRenderTimeout() {
return clampNumber(Number(this.mergedProps.fullRenderTimeoutMs ?? 4000), 200, 20_000)
}
get strictMode() {
return this.mergedProps.isStrict === true
}
get resolvedLoading() {
if (typeof this.mergedProps.loading === 'boolean')
return this.mergedProps.loading
return (this.node as any)?.loading === true
}
get resolvedProgressiveRender() {
return this.mergedProps.progressiveRender === true
}
get previewTransform() {
return `scale(${this.zoom})`
}
get modalTransform() {
return `scale(${Math.max(1, this.zoom)})`
}
ngAfterViewInit() {
this.viewReady = true
queueMicrotask(() => void this.renderDiagram())
}
ngOnChanges() {
if (!this.viewReady)
return
queueMicrotask(() => void this.renderDiagram())
}
ngOnDestroy() {
this.destroyed = true
this.renderToken += 1
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
}
@HostListener('window:keydown', ['$event'])
handleWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && this.modalOpen)
this.closeModal()
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
setMode(nextSource: boolean) {
this.showSource = nextSource
if (!nextSource)
this.scheduleHostSync()
this.cdr.markForCheck()
}
async copySource() {
await copyTextToClipboard(this.code)
this.copied = true
this.context?.events.onCopy?.(this.code)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
exportSvg() {
if (!this.svgMarkup)
return
downloadSvgMarkup(this.svgMarkup, `mermaid-${Date.now()}.svg`)
}
openModal() {
if (!this.svgMarkup)
return
this.modalOpen = true
if (typeof document !== 'undefined')
document.body.style.overflow = 'hidden'
queueMicrotask(() => this.syncSvgHosts())
this.cdr.markForCheck()
}
closeModal() {
this.modalOpen = false
if (typeof document !== 'undefined')
document.body.style.overflow = ''
this.cdr.markForCheck()
}
toggleCollapsed() {
this.collapsed = !this.collapsed
if (!this.collapsed)
queueMicrotask(() => void this.renderDiagram())
this.cdr.markForCheck()
}
adjustZoom(delta: number) {
this.zoom = clampNumber(Number((this.zoom + delta).toFixed(2)), 0.4, 2.5)
this.cdr.markForCheck()
}
resetZoom() {
this.zoom = 1
this.cdr.markForCheck()
}
handleModalWheel(event: WheelEvent) {
if (this.mergedProps.enableWheelZoom !== true)
return
event.preventDefault()
this.adjustZoom(event.deltaY > 0 ? -0.08 : 0.08)
}
private async renderDiagram() {
if (this.destroyed || !this.viewReady || this.collapsed)
return
const source = this.code.trim()
if (!source) {
this.svgMarkup = ''
this.error = ''
this.syncSvgHosts()
this.cdr.markForCheck()
return
}
if (this.resolvedLoading && !this.resolvedProgressiveRender) {
this.renderToken += 1
this.rendering = true
this.error = ''
this.svgMarkup = ''
this.syncSvgHosts()
this.cdr.markForCheck()
return
}
const token = ++this.renderToken
this.rendering = true
this.error = ''
this.cdr.markForCheck()
try {
const mermaid = await getMermaid(this.strictMode
? {
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
flowchart: { htmlLabels: false },
}
: {
startOnLoad: false,
securityLevel: 'loose',
suppressErrorRendering: true,
})
if (!mermaid)
throw new Error('Mermaid renderer is not available.')
const theme: MermaidTheme = this.resolvedIsDark ? 'dark' : 'light'
let renderSource = source
if (this.resolvedLoading && this.resolvedProgressiveRender) {
try {
const res = await this.canParseOrPrefix(source, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
return
if (!res.fullOk && !res.prefix) {
this.rendering = false
this.cdr.markForCheck()
return
}
if (res.prefix)
renderSource = res.prefix
}
catch (error: any) {
if (error?.name === 'AbortError')
return
this.rendering = false
this.cdr.markForCheck()
return
}
}
const renderId = `markstream-angular-mermaid-${++mermaidRenderSequence}`
const themedSource = this.applyThemeTo(renderSource, theme)
const rendered = await enqueueMermaidRender(() => this.withTimeout(
() => Promise.resolve(mermaid.render(renderId, themedSource)),
this.resolvedLoading && this.resolvedProgressiveRender
? this.resolvedRenderTimeout
: this.resolvedFullRenderTimeout,
))
if (this.destroyed || token !== this.renderToken)
return
const svg = typeof rendered === 'string' ? rendered : rendered?.svg
if (this.isBrokenMermaidSvg(svg))
throw new Error('Mermaid produced invalid SVG during preview')
const safeSvg = toSafeSvgMarkup(svg)
if (!safeSvg)
throw new Error('Mermaid returned empty output.')
this.svgMarkup = safeSvg
if (!this.svgMarkup)
this.showSource = true
this.syncSvgHosts()
}
catch (error) {
if (this.destroyed || token !== this.renderToken)
return
if (this.resolvedLoading && this.resolvedProgressiveRender) {
this.svgMarkup = ''
this.syncSvgHosts()
return
}
// Allow consumer to handle the error via onRenderError callback
const onRenderError = this.mergedProps.onRenderError
if (typeof onRenderError === 'function' && this.previewHost?.nativeElement) {
const handled = onRenderError(error, this.code, this.previewHost.nativeElement)
if (handled === true) {
this.svgMarkup = ''
return
}
}
this.svgMarkup = ''
this.showSource = true
this.error = error instanceof Error ? error.message : 'Failed to render Mermaid diagram.'
this.syncSvgHosts()
}
finally {
if (token === this.renderToken) {
this.rendering = false
this.cdr.markForCheck()
}
}
}
private syncSvgHosts() {
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
if (this.modalOpen)
setElementHtml(this.modalHost?.nativeElement, this.svgMarkup)
else
setElementHtml(this.modalHost?.nativeElement, '')
}
private scheduleHostSync() {
if (typeof window !== 'undefined') {
window.setTimeout(() => {
if (this.destroyed)
return
this.syncSvgHosts()
this.cdr.markForCheck()
}, 0)
return
}
queueMicrotask(() => this.syncSvgHosts())
}
private async canParseWithFallback(source: string, theme: MermaidTheme, mermaid: any, token: number) {
try {
const canParse = await canParseOffthread(source, theme, this.resolvedWorkerTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
if (canParse)
return true
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
const themedSource = this.applyThemeTo(source, theme)
const anyMermaid = mermaid as any
if (typeof anyMermaid.parse === 'function') {
await this.withTimeout(() => Promise.resolve(anyMermaid.parse(themedSource)), this.resolvedParseTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return true
}
const renderId = `markstream-angular-mermaid-parse-${++mermaidRenderSequence}`
await this.withTimeout(() => Promise.resolve(anyMermaid.render(renderId, themedSource)), this.resolvedParseTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return true
}
private async canParseOrPrefix(source: string, theme: MermaidTheme, mermaid: any, token: number) {
if (this.getMermaidDiagramKind(source) === 'gantt') {
const prefix = this.getSafePrefixCandidate(source)
if (!prefix.trim())
return { fullOk: false, prefix: '' }
await this.canParseWithFallback(prefix, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return prefix === source ? { fullOk: true, prefix: '' } : { fullOk: false, prefix }
}
try {
await this.canParseWithFallback(source, theme, mermaid, token)
return { fullOk: true, prefix: '' }
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
const prefix = await this.findPrefixCandidate(source, theme, token)
if (!prefix)
return { fullOk: false, prefix: '' }
await this.canParseWithFallback(prefix, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return { fullOk: false, prefix }
}
private async findPrefixCandidate(source: string, theme: MermaidTheme, token: number) {
try {
const prefix = await findPrefixOffthread(source, theme, this.resolvedWorkerTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
if (prefix)
return prefix
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
return this.getSafePrefixCandidate(source)
}
private applyThemeTo(source: string, theme: MermaidTheme) {
const trimmed = source.trimStart()
if (trimmed.startsWith('%%{'))
return source
const themeValue = theme === 'dark' ? 'dark' : 'default'
return `%%{init: {"theme": "${themeValue}"}}%%\n${source}`
}
private getMermaidDiagramKind(source: string) {
for (const rawLine of source.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
continue
const match = line.match(/^([A-Z][\w-]*)\b/i)
return match?.[1]?.toLowerCase() || ''
}
return ''
}
private isGanttTaskLine(rawLine: string) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
return false
if (/^(?:gantt|title|dateformat|axisformat|tickinterval|excludes|section|todaymarker|topaxis|weekday|weekend|acctitle|accdescr|accdescrmultiline)\b/i.test(line))
return false
return line.includes(':')
}
private getSafeGanttPreviewCandidate(source: string) {
const lines = source.split(/\r?\n/)
if (!/\r?\n$/.test(source) && lines.length > 0)
lines.pop()
while (lines.length > 0) {
const last = lines[lines.length - 1]?.trim()
if (!last || last.startsWith('%%')) {
lines.pop()
continue
}
if (this.isGanttTaskLine(last))
break
lines.pop()
}
return lines.some(line => this.isGanttTaskLine(line)) ? lines.join('\n') : ''
}
private getSafePrefixCandidate(source: string) {
if (this.getMermaidDiagramKind(source) === 'gantt')
return this.getSafeGanttPreviewCandidate(source)
const lines = source.split('\n')
while (lines.length > 0) {
const lastRaw = lines[lines.length - 1]
const last = lastRaw.trimEnd()
if (!last) {
lines.pop()
continue
}
const looksDangling = /^[-=~>|<\s]+$/.test(last.trim())
|| /(?:--|==|~~|->|<-|-\||-\)|-x|o-|\|-|\.-)\s*$/.test(last)
|| /[-|><]$/.test(last)
|| /(?:graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt)\s*$/i.test(last)
if (!looksDangling)
break
lines.pop()
}
return lines.join('\n')
}
private isBrokenMermaidSvg(svg: string | null | undefined) {
if (!svg || typeof DOMParser === 'undefined')
return !svg
const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml')
const svgEl = parsed.documentElement
if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg')
return true
const viewBox = svgEl.getAttribute('viewBox')
if (viewBox) {
const parts = viewBox.trim().split(/[\s,]+/)
if (parts.length === 4) {
const width = Number.parseFloat(parts[2] || '')
const height = Number.parseFloat(parts[3] || '')
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
return true
}
}
const nodes = [svgEl, ...Array.from(svgEl.querySelectorAll('*'))]
for (const node of nodes) {
for (const attr of Array.from(node.attributes)) {
if (/\bNaN\b/i.test(attr.value))
return true
if (attr.name === 'style' && /max-width:\s*0(?:px)?/i.test(attr.value))
return true
}
}
return false
}
private withTimeout<T>(run: () => Promise<T>, timeoutMs: number) {
return new Promise<T>((resolve, reject) => {
let settled = false
const timer = typeof window !== 'undefined'
? window.setTimeout(() => {
if (settled)
return
settled = true
reject(new Error('Operation timed out'))
}, timeoutMs)
: null
run()
.then((value) => {
if (settled)
return
settled = true
if (timer != null && typeof window !== 'undefined')
window.clearTimeout(timer)
resolve(value)
})
.catch((error) => {
if (settled)
return
settled = true
if (timer != null && typeof window !== 'undefined')
window.clearTimeout(timer)
reject(error)
})
})
}
}