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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ is processed by the agent and goes to its model's provider, unless the model
runs on your computer. Setup per agent, troubleshooting and every option are in
[mcp/README.md](mcp/README.md).

## Desktop shell

An Electron shell for Windows lives in [desktop/](desktop/README.md): it runs the
local build with no browser, picks the graphics backend (DirectX 11/12, Vulkan,
OpenGL), translates the page at run time (Portuguese today), opens Creality Scan
`project.obp` files, and pairs the agent relay with the window you have open.

## Automatic reverse engineering

An AI agent can drive Reverse engineer from start to finish: give it a scan
Expand Down
2 changes: 2 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
release/
release-new/
50 changes: 50 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ScanRuler Desktop

An Electron shell around the ScanRuler build in this repository: the app runs
from the local `dist/`, with no browser and no scanruler.com, paired with the
`scanruler-mcp` relay so agents reach the window you are looking at.

```bash
# in the repository root: npm ci && npm --prefix mcp ci && npm run build
cd desktop
npm install
npm start # runs the app from the checkout
npm run dist # packages release/win-unpacked/ScanRuler.exe (@electron/packager)
npm test # dictionaries, smoke (isolation, threads, WebGL2, WebGPU), Creality watcher
node test/i18n.mjs # the page stays translated as React redraws
node test/e2e-agent.mjs # an MCP client reaches the page through the relay
```

Windows x64 only for now. Packaged with `@electron/packager`, not electron-builder, whose bundled 7-Zip is LGPL (see CONTRIBUTING.md).

## What it adds

- **Graphics backend**: Help → Hardware picks the backend ANGLE translates WebGL
to — DirectX 11 (default), DirectX 12, Vulkan or OpenGL — and shows the GPU,
WebGPU, thread count, cross-origin isolation and WebNN. A change applies on
restart; the window title names the API in use. WebGPU shows as available only
on DirectX 11: Chromium on Windows hands out no WebGPU adapter under the
Vulkan or OpenGL ANGLE backends (verified on Chromium 156, every
`--use-webgpu-adapter` value) — ScanRuler draws with WebGL2 and does not use it.
- **Language**: the ScanRuler page is translated at run time, node by node, from
`i18n/<lang>.json`, and kept translated as React redraws; the source of the
app is not touched. Shipped: Deutsch, Español, Français, Italiano, 日本語,
Português (Brasil), 简体中文. The first language follows the OS; the Language
menu changes it; the shell's own menu and Hardware panel follow. To add one,
copy `i18n/_english-keys.json` to `i18n/<lang>.json` as `{key: translation}`
with `_name`, `_menu` and `_hw` as in the others — `node test/dicts.mjs`
checks it. Text drawn on canvas and numbers built at run time are not covered.
- **Creality Scan projects**: `project.obp` opens from the menu, the command
line, drag and drop or the file picker, resolving to the project's
`result/Mesh.ply` (or the newest session's `Fused.ply`). File → Watch Creality
Scan folder… offers each new `Mesh.ply` the scanner software writes.
- **Agents on the open window**: the app starts the relay itself and writes
`agent.json` (port and token) under its user-data folder; `scanruler-mcp.cmd`
next to the exe joins that app, so Claude Code or Codex drive the window the
user has open instead of a background copy:

```bash
claude mcp add --scope user scanruler -- "<path>\release\win-unpacked\scanruler-mcp.cmd"
codex mcp add scanruler -- "<path>\release\win-unpacked\scanruler-mcp.cmd"
```

21 changes: 21 additions & 0 deletions desktop/agent-bridge.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Entry of scanruler-mcp.cmd: joins the ScanRuler app that is open — its port and token are
// kept in agent.json beside its settings — then runs the agent server. Without the app
// open (no file), or with SCANRULER_MCP_TOKEN / _PORT set by hand, nothing changes.
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const file = process.env.SCANRULER_AGENT_FILE ?? join(process.env.APPDATA ?? '', 'scanruler-desktop', 'agent.json')
try {
const a = JSON.parse(readFileSync(file, 'utf8'))
process.env.SCANRULER_MCP_TOKEN ||= a.token
process.env.SCANRULER_MCP_PORT ||= String(a.port)
// The app removes the file when it quits. Left running, this server would take the
// app's port over and the next app could not start; so it leaves with the app.
setInterval(() => {
if (!existsSync(file)) process.exit(0)
}, 2000)
} catch {}
await import(pathToFileURL(join(here, 'mcp/bin/scanruler-mcp.js')).href)
55 changes: 55 additions & 0 deletions desktop/creality.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Watches a folder of Creality Scan projects for a finished <project>/<data>/result/Mesh.ply
// (the scan's final mesh) and says when one appears or changes, once its file stops growing.
import { watch, statSync, readFileSync, existsSync } from 'node:fs'
import { dirname, join, relative, sep } from 'node:path'

const MESH = /[\\/]result[\\/]Mesh\.ply$/i
const SETTLE_MS = 1500

export const isMesh = (path) => MESH.test(path)

/** Calls onMesh(path, project) for each Mesh.ply under `dir` once its size is steady; returns stop(). */
export function watchMeshes(dir, onMesh, { settleMs = SETTLE_MS } = {}) {
const waiting = new Map() // path -> timer
const check = (path, last) => {
let size
try {
size = statSync(path).size
} catch {
return waiting.delete(path) // gone again before it settled
}
if (size > 0 && size === last) {
waiting.delete(path)
return onMesh(path, relative(dir, path).split(sep)[0])
}
waiting.set(path, setTimeout(() => check(path, size), settleMs))
}
const watcher = watch(dir, { recursive: true }, (_event, name) => {
if (!name) return
const path = `${dir}${sep}${name}`
if (!isMesh(path)) return
clearTimeout(waiting.get(path))
waiting.set(path, setTimeout(() => check(path, -1), settleMs))
})
return () => {
watcher.close()
for (const t of waiting.values()) clearTimeout(t)
waiting.clear()
}
}

/** The scan a project.obp stands for: its final Mesh.ply, else the newest session's Fused.ply. */
export function meshOfProject(obp) {
const dir = dirname(obp)
const text = readFileSync(obp, 'utf8')
const data = /^data_dir:\s*(\S+)/m.exec(text)?.[1]
const final = data && join(dir, data, 'result', 'Mesh.ply')
if (final && existsSync(final)) return final
const sessions = [...text.matchAll(/^\s*-\s*(\S+)\s*$/gm)]
.map((m) => join(dir, m[1], 'result', 'Fused.ply'))
.filter((f) => existsSync(f))
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
if (sessions.length) return sessions[0]
throw new Error('Este projeto do Creality Scan ainda não tem malha (Mesh.ply) nem scan fundido (Fused.ply).')
}
55 changes: 55 additions & 0 deletions desktop/hardware.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!doctype html>
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<html lang="en">
<meta charset="utf-8">
<title>ScanRuler — Hardware</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
<style>
body { font: 14px system-ui, sans-serif; margin: 20px; background: #15171a; color: #e6e8eb }
h1 { font-size: 16px; margin: 0 0 12px }
table { border-collapse: collapse; width: 100% }
td { padding: 4px 8px; border-bottom: 1px solid #2b2f35; vertical-align: top }
td:first-child { color: #9aa3ad; width: 200px }
select, button { font: inherit; padding: 4px 8px }
.row { margin: 14px 0; display: flex; gap: 8px; align-items: center }
.warn { color: #e0b050 }
</style>
<h1 id="h">Hardware</h1>
<table id="t"></table>
<div class="row">
<label for="b" id="l"></label>
<select id="b"></select>
<button id="r"></button>
</div>
<p class="warn" id="note"></p>
<script>
const $ = (id) => document.getElementById(id)
const row = (k, v) => `<tr><td>${k}</td><td>${String(v).replace(/[<&]/g, (c) => ({ '<': '&lt;', '&': '&amp;' })[c])}</td></tr>`
async function load() {
const i = await hw.info()
const s = i.strings
// A probe answer is a description, true (present, unnamed) or false (absent).
const present = (v, name) => (v === false ? s.unavailable : v === true ? name ?? s.available : v)
document.title = `ScanRuler — ${s.title}`
$('h').textContent = s.title
$('l').textContent = s.backend
$('r').textContent = s.restart
$('t').innerHTML = [
row(s.gpu, i.gpus.join(' | ') || '—'),
row(s.backendChosen, i.backends[i.backendRunning]),
row(s.backendRunning, i.angleActive || '—'),
row(s.renderer, present(i.page.renderer, 'WebGL2')),
row(s.webgpu, i.page.webgpu === false && i.backendRunning !== 'd3d11' ? `${s.unavailable} — ${s.webgpuWhy}` : present(i.page.webgpu)),
row(s.cpu, `${i.cpu} — ${i.page.threads} ${s.threads}`),
row(s.isolation, i.page.isolated ? s.yes : s.no),
row(s.npu, i.page.webnn ? s.available : s.unavailable),
row(s.versions, i.versions),
].join('')
$('b').innerHTML = Object.entries(i.backends).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')
$('b').value = i.backend
$('note').textContent = i.backend !== i.backendRunning ? s.changed : ''
}
$('b').onchange = async () => { await hw.setBackend($('b').value); load() }
$('r').onclick = () => hw.restart()
load()
</script>
137 changes: 137 additions & 0 deletions desktop/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Runtime translation of the ScanRuler page: the English text the app draws is
// swapped for the dictionary's text, node by node, and kept in step as React
// redraws. The ScanRuler source is not touched. Text drawn on a canvas (3D
// labels) and text built from numbers at run time are not covered.
import { readdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))

// The shell's own text, in English; a dictionary's "_menu" and "_hw" override it.
const MENU = { file: 'File', open: 'Open scan…', quit: 'Quit', view: 'View', help: 'Help', hardware: 'Hardware…', language: 'Language', watch: 'Watch Creality Scan folder…', unwatch: 'Stop watching Creality Scan', fresh: 'New Mesh.ply from project', openIt: 'Open in ScanRuler', ignore: 'Ignore' }
const HW = { title: 'Hardware', gpu: 'GPU', backendChosen: 'ANGLE backend chosen', backendRunning: 'ANGLE backend running', renderer: 'WebGL2 / renderer', webgpu: 'WebGPU', cpu: 'CPU (threads)', threads: 'logical threads', isolation: 'Cross-origin isolation (WASM threads)', npu: 'NPU / WebNN', versions: 'Electron / Chromium', backend: 'Graphics backend:', restart: 'Restart to apply', changed: 'Backend changed: restart to apply.', yes: 'yes', no: 'no', available: 'available', unavailable: 'unavailable', webgpuWhy: 'Chromium on Windows offers WebGPU only on the DirectX 11 backend; ScanRuler does not use it.', default: 'default', experimental: 'experimental' }

const files = new Map() // lang -> parsed i18n/<lang>.json
const read = (lang) => {
if (!files.has(lang)) {
try {
files.set(lang, JSON.parse(readFileSync(join(here, 'i18n', `${lang}.json`), 'utf8')))
} catch {
files.set(lang, null)
}
}
return files.get(lang)
}

/** Every language with a file in i18n/, plus English: id -> its own name. */
export const LANGUAGES = Object.fromEntries([
['en', 'English'],
...readdirSync(join(here, 'i18n'))
.filter((f) => f.endsWith('.json') && !f.startsWith('_')) // _english-keys.json lists the source strings for translators
.map((f) => f.slice(0, -5))
.map((lang) => [lang, read(lang)?._name ?? lang]),
])

/** The language to start in: the OS's, where a file for it exists, else English. */
export function defaultLanguage(locale) {
const ids = Object.keys(LANGUAGES)
return ids.find((id) => id.toLowerCase() === locale.toLowerCase()) ?? ids.find((id) => id.split('-')[0] === locale.split('-')[0]) ?? 'en'
}

/** The page's words of a language, or null for English (the page as it is). */
export function loadDict(lang) {
const d = read(lang)
return d ? Object.fromEntries(Object.entries(d).filter(([k]) => !k.startsWith('_'))) : null
}

export const menuOf = (lang) => ({ ...MENU, ...read(lang)?._menu })
export const hwOf = (lang) => ({ ...HW, ...read(lang)?._hw })

/** A script that installs the translator in the page, or switches its dictionary. */
export function injectScript(dict) {
return `(${engine.toString()})(${JSON.stringify(dict)})`
}

function engine(dict) {
if (window.__srI18n) return window.__srI18n.set(dict)
const ATTRS = ['title', 'aria-label', 'placeholder', 'alt']
const tracked = new Set() // WeakRef of {node, attr?, orig, mine}
const byTarget = new WeakMap() // node|element -> {text?: rec, attrs: {name: rec}}
let words = null

const skip = (el) => !el || /^(SCRIPT|STYLE|TEXTAREA|CODE|PRE)$/.test(el.tagName) || el.isContentEditable || el.closest?.('[data-no-i18n]')
const split = (s) => [s.match(/^\s*/)[0], s.trim(), s.match(/\s*$/)[0]]

function setText(node) {
if (skip(node.parentElement)) return
let rec = byTarget.get(node)?.text
if (rec && node.nodeValue === rec.mine) return // our own write
const [lead, core, tail] = split(node.nodeValue)
if (!core) return
const to = words?.[core]
if (!to) { if (rec) rec.mine = null; return }
if (!rec) {
rec = { ref: new WeakRef(node), kind: 'text' }
byTarget.set(node, { ...(byTarget.get(node) ?? { attrs: {} }), text: rec })
tracked.add(rec)
}
rec.orig = node.nodeValue
rec.mine = lead + to + tail
node.nodeValue = rec.mine
}

function setAttr(el, name) {
const cur = el.getAttribute(name)
if (cur == null) return
const entry = byTarget.get(el) ?? { attrs: {} }
byTarget.set(el, entry)
let rec = entry.attrs[name]
if (rec && cur === rec.mine) return
const to = words?.[cur.trim()]
if (!to) return
if (!rec) { rec = { ref: new WeakRef(el), kind: 'attr', name }; entry.attrs[name] = rec; tracked.add(rec) }
rec.orig = cur
rec.mine = to
el.setAttribute(name, to)
}

function walk(root) {
if (!words) return
if (root.nodeType === Node.TEXT_NODE) return setText(root)
if (root.nodeType !== Node.ELEMENT_NODE) return
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
for (let n; (n = w.nextNode()); ) setText(n)
const els = root.matches?.('[title],[aria-label],[placeholder],[alt]') ? [root] : []
for (const el of [...els, ...root.querySelectorAll('[title],[aria-label],[placeholder],[alt]')]) for (const a of ATTRS) setAttr(el, a)
}

function restore() {
for (const rec of tracked) {
const t = rec.ref.deref()
if (!t) { tracked.delete(rec); continue }
if (rec.kind === 'text' && t.nodeValue === rec.mine) t.nodeValue = rec.orig
if (rec.kind === 'attr' && t.getAttribute(rec.name) === rec.mine) t.setAttribute(rec.name, rec.orig)
rec.mine = null
}
}

const observer = new MutationObserver((list) => {
for (const m of list) {
if (m.type === 'childList') m.addedNodes.forEach(walk)
else if (m.type === 'characterData') setText(m.target)
else if (m.type === 'attributes') setAttr(m.target, m.attributeName)
}
})
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ATTRS })

window.__srI18n = {
set(next) {
restore()
words = next
if (words) walk(document.body)
},
}
window.__srI18n.set(dict)
}
Loading
Loading