Merge pull request #481 from open-pencil/release-v014-smoke

fix(app): keep desktop startup console clean
This commit is contained in:
Danila Poyarkov 2026-08-11 14:56:55 +03:00 committed by GitHub
commit c29654cd07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 258 additions and 45 deletions

View file

@ -53,6 +53,8 @@
### Fixed
- Keep desktop startup clean when optional MCP automation is unavailable, retain startup diagnostics for MCP-dependent features, and load JSX syntax highlighting without browser globals.
- Preserve Figmas imported glyph outlines through layout and appearance updates so text keeps its intended weight and shape.
- Keep swapped image avatars and thin stepper dividers at their effective imported size and position.
- Scale proportion-constrained `.fig` instance geometry through fixed wrapper layers so imported logos and icons retain their intended size.

View file

@ -43,12 +43,13 @@
"dedent": "^1.7.1",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"hast-util-to-html": "^9.0.5",
"idb": "^8.0.3",
"jspdf": "^4.2.1",
"lib0": "^0.2.117",
"motion-v": "^2.0.0",
"opentype.js": "^2.0.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0",
"reka-ui": "^2.9.0",
"svg2pdf.js": "^2.7.0",
"tailwind-merge": "^3.5.0",
@ -84,7 +85,6 @@
"@types/culori": "^4.0.1",
"@types/node": "^25.3.2",
"@types/opentype.js": "^1.3.9",
"@types/prismjs": "^1.26.6",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
"@vitejs/plugin-vue": "^6.0.5",
@ -117,7 +117,7 @@
},
"packages/cli": {
"name": "@open-pencil/cli",
"version": "0.13.2",
"version": "0.14.0",
"bin": {
"openpencil": "./bin/openpencil.js",
},
@ -136,7 +136,7 @@
},
"packages/core": {
"name": "@open-pencil/core",
"version": "0.13.2",
"version": "0.14.0",
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
@ -193,7 +193,7 @@
},
"packages/dom-css": {
"name": "@open-pencil/dom-css",
"version": "0.13.2",
"version": "0.14.0",
"dependencies": {
"@acemir/cssom": "^0.9.31",
"parse5": "^8.0.1",
@ -213,7 +213,7 @@
},
"packages/fig": {
"name": "@open-pencil/fig",
"version": "0.13.2",
"version": "0.14.0",
"dependencies": {
"es-toolkit": "^1.46.1",
"fflate": "^0.8.2",
@ -230,7 +230,7 @@
},
"packages/kiwi": {
"name": "@open-pencil/kiwi",
"version": "0.13.2",
"version": "0.14.0",
"dependencies": {
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
@ -243,7 +243,7 @@
},
"packages/mcp": {
"name": "@open-pencil/mcp",
"version": "0.13.2",
"version": "0.14.0",
"bin": {
"openpencil-mcp": "./dist/stdio.mjs",
"openpencil-mcp-http": "./dist/index.mjs",
@ -264,7 +264,7 @@
},
"packages/pen": {
"name": "@open-pencil/pen",
"version": "0.13.2",
"version": "0.14.0",
"dependencies": {
"culori": "^4.0.2",
},
@ -280,7 +280,7 @@
},
"packages/scene-graph": {
"name": "@open-pencil/scene-graph",
"version": "0.13.2",
"version": "0.14.0",
"dependencies": {
"es-toolkit": "^1.46.1",
"nanoevents": "^9.1.0",
@ -294,7 +294,7 @@
},
"packages/vue": {
"name": "@open-pencil/vue",
"version": "0.13.2",
"version": "0.14.0",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.9",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
@ -1589,6 +1589,8 @@
"character-parser": ["character-parser@2.2.0", "", { "dependencies": { "is-regex": "^1.0.3" } }, "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@ -1961,10 +1963,14 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
@ -2025,6 +2031,10 @@
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
@ -2043,6 +2053,8 @@
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="],
@ -2061,6 +2073,8 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
"is-loopback-addr": ["is-loopback-addr@2.0.2", "", {}, "sha512-26POf2KRCno/KTNL5Q0b/9TYnL00xEsSaLfiFRmjM7m7Lw7ZMmFybzzuX4CcsLAluZGd+niLUiMRxEooVE3aqg=="],
@ -2527,6 +2541,8 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
@ -2591,8 +2607,6 @@
"prexit": ["prexit@2.3.0", "", {}, "sha512-mX+LIbtS0anKtl2PykYabxninwloblUQMRO6CubeSmjxb+kKlATuJoH9UeN8NLE4TgIEFWfBXw7V3GkWbBrSmg=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
@ -2685,6 +2699,8 @@
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="],
"regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
"regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="],
@ -3485,6 +3501,8 @@
"p-queue/p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],

31
desktop/Cargo.lock generated
View file

@ -967,6 +967,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "embed-resource"
version = "3.0.6"
@ -1023,6 +1029,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
@ -2957,6 +2969,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"which",
"zip 2.4.2",
"zstd",
]
@ -5897,6 +5910,18 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "which"
version = "7.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix",
"winsafe",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -6372,6 +6397,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wio"
version = "0.2.2"

View file

@ -29,6 +29,7 @@ tauri-plugin-fs = { version = "2.4.5", features = ["watch"] }
tauri-plugin-shell = "2"
zstd = "0.13"
zip = { version = "2", default-features = false }
which = "7.0.3"
font-kit = "0.14.3"
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", branch = "dev" }
tauri-plugin-updater = "2"

View file

@ -41,6 +41,11 @@ fn take_pending_open(state: tauri::State<PendingOpen>) -> Vec<PendingOpenFile> {
.unwrap_or_default()
}
#[tauri::command]
fn mcp_executable_available() -> bool {
which::which("openpencil-mcp-http").is_ok()
}
fn file_association_path(path: PathBuf) -> Option<PathBuf> {
let path = path.canonicalize().ok()?;
if !path.is_file() {
@ -128,6 +133,7 @@ pub fn run() {
credential_status,
credential_store_availability,
credential_write,
mcp_executable_available,
list_system_fonts,
load_system_font,
proxy_http_request,

View file

@ -96,12 +96,13 @@
"dedent": "^1.7.1",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"hast-util-to-html": "^9.0.5",
"idb": "^8.0.3",
"jspdf": "^4.2.1",
"lib0": "^0.2.117",
"motion-v": "^2.0.0",
"opentype.js": "^2.0.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0",
"reka-ui": "^2.9.0",
"svg2pdf.js": "^2.7.0",
"tailwind-merge": "^3.5.0",
@ -137,7 +138,6 @@
"@types/culori": "^4.0.1",
"@types/node": "^25.3.2",
"@types/opentype.js": "^1.3.9",
"@types/prismjs": "^1.26.6",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
"@vitejs/plugin-vue": "^6.0.5",

View file

@ -28,8 +28,26 @@ const DEV_AUTOMATION_AUTH_TOKEN =
const APP_VERSION =
typeof __OPENPENCIL_APP_VERSION__ === 'string' ? __OPENPENCIL_APP_VERSION__ : '0.0.0-test'
const noop = () => undefined
const MAX_STARTUP_STDERR_LENGTH = 8_192
const MCP_EXECUTABLE = 'openpencil-mcp-http'
let runtimeAutomationAuthToken: string | null = DEV_AUTOMATION_AUTH_TOKEN
let runtimeAutomationStartupError: Error | null = null
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function missingMCPError(): Error {
return new Error(
`MCP automation is not installed. Install @open-pencil/mcp@${APP_VERSION} globally with your package manager, then restart OpenPencil.`
)
}
function rememberStartupError(error: unknown): null {
runtimeAutomationStartupError = toError(error)
return null
}
/**
* Reads the auth token from the MCP discovery file via Tauri's FS plugin.
@ -138,8 +156,7 @@ async function readHealth(): Promise<AutomationHealth | null> {
})
if (!res.ok) return null
return (await res.json()) as AutomationHealth
} catch (e) {
console.error('[MCP] health check failed:', e instanceof Error ? e.message : e)
} catch {
return null
}
}
@ -156,7 +173,7 @@ function parseMajorMinor(version: string): string | null {
function assertCompatibleMCPVersion(health: AutomationHealth): void {
const runningMajorMinor = health.version ? parseMajorMinor(health.version) : null
const oursMajorMinor = parseMajorMinor(APP_VERSION)
if (!runningMajorMinor || !oursMajorMinor) return // unparseable — don't block
if (!oursMajorMinor) return
if (runningMajorMinor === oursMajorMinor) return
const runningVersion = health.version ? `v${health.version}` : 'an older version'
const updateHint = health.installCommand
@ -181,6 +198,7 @@ export async function getAutomationAuthToken(): Promise<string | null> {
if (runtimeAutomationAuthToken) return runtimeAutomationAuthToken
const health = await readHealth()
if (!health) {
if (runtimeAutomationStartupError) throw runtimeAutomationStartupError
throw new Error(
'MCP server is not reachable. Ensure the desktop app is running and the MCP server has started.'
)
@ -212,14 +230,17 @@ export async function getAutomationAuthToken(): Promise<string | null> {
return runtimeAutomationAuthToken
}
export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null> {
async function startMCPIfNeeded(): Promise<AutomationServerHandle | null> {
runtimeAutomationStartupError = null
if (import.meta.env.DEV || !isTauri()) {
return DEV_AUTOMATION_AUTH_TOKEN
? { disconnect: noop, authToken: DEV_AUTOMATION_AUTH_TOKEN }
: null
}
const existing = await readHealth()
const expectedDiscoveryPath = await computeExpectedDiscoveryPath()
const hasDiscovery = await discoveryFileExists(expectedDiscoveryPath)
const existing = hasDiscovery ? await readHealth() : null
if (existing) {
assertCompatibleMCPVersion(existing)
const discoveryPath = await resolveDiscoveryPath(existing.discoveryPath)
@ -237,6 +258,10 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
}
}
const { invoke } = await import('@tauri-apps/api/core')
const executableAvailable = await invoke<boolean>('mcp_executable_available')
if (!executableAvailable) return rememberStartupError(missingMCPError())
const authToken = randomHex(32)
// Cache only after MCP startup is confirmed healthy.
@ -247,7 +272,7 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
// The app bundle directory (Tauri executableDir) is read-only and would
// cause EACCES errors on every file write.
const mcpRoot = await resolveTauriHomeDir()
const resolved = resolvePlatformCommand('openpencil-mcp-http')
const resolved = resolvePlatformCommand(MCP_EXECUTABLE)
const command = Command.create(resolved.command, resolved.args, {
env: {
PORT: String(AUTOMATION_HTTP_PORT),
@ -258,19 +283,40 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
}
})
let startupStderr = ''
command.stderr.on('data', (raw: Uint8Array | number[] | string) => {
console.error('[MCP]', decodeTauriStderr(raw))
if (startupStderr.length >= MAX_STARTUP_STDERR_LENGTH) return
startupStderr += decodeTauriStderr(raw).slice(
0,
MAX_STARTUP_STDERR_LENGTH - startupStderr.length
)
})
let spawnedToken: string | null = null
command.on('close', (data: { code: number | null }) => {
console.error(`[MCP] Server exited (code ${data.code ?? 'null'})`)
if (spawnedToken && runtimeAutomationAuthToken === spawnedToken) {
runtimeAutomationAuthToken = null
}
let child: Awaited<ReturnType<typeof command.spawn>>
const childClosed = new Promise<{ code: number | null; signal: number | null }>((resolve) => {
command.on('close', (event) => {
resolve(event)
if (spawnedToken && runtimeAutomationAuthToken === spawnedToken) {
runtimeAutomationAuthToken = null
}
})
})
const child = await command.spawn()
try {
child = await command.spawn()
} catch (error) {
return rememberStartupError(error)
}
const earlyExit = await Promise.race([childClosed, promiseTimeout(250).then(() => null)])
if (earlyExit) {
const details = startupStderr.trim()
return rememberStartupError(
new Error(
`MCP server exited before startup completed (code ${earlyExit.code ?? 'null'}, signal ${earlyExit.signal ?? 'null'})${details ? `: ${details}` : '.'}`
)
)
}
const health = await pollHealth(5, 1000)
if (health) {
@ -281,6 +327,7 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
const token = discovered ?? authToken
spawnedToken = token
runtimeAutomationAuthToken = token
runtimeAutomationStartupError = null
return {
disconnect: () => {
void child.kill().catch((e) => {
@ -304,11 +351,21 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
} finally {
runtimeAutomationAuthToken = null
}
throw new Error(
`Failed to start MCP server. Install @open-pencil/mcp@${APP_VERSION} globally with your package manager, then restart OpenPencil.`
return rememberStartupError(
new Error(
`MCP server did not become healthy within the startup timeout${startupStderr.trim() ? `: ${startupStderr.trim()}` : '.'}`
)
)
}
export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null> {
try {
return await startMCPIfNeeded()
} catch (error) {
return rememberStartupError(error)
}
}
/**
* Returns the user's home directory. Used as the default OPENPENCIL_MCP_ROOT
* so file-scoped tools operate on paths inside ~, which is writable and

View file

@ -0,0 +1,9 @@
import { toHtml as toHTML } from 'hast-util-to-html'
import { refractor } from 'refractor/core'
import jsx from 'refractor/jsx'
refractor.register(jsx)
export function highlightJSX(code: string): string {
return toHTML(refractor.highlight(code, 'jsx'))
}

View file

@ -1,6 +1,4 @@
<script setup lang="ts">
import Prism from 'prismjs'
import 'prismjs/components/prism-jsx'
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { useClipboard } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
@ -8,6 +6,7 @@ import { computed, ref, watch } from 'vue'
import { JSX_REFERENCE, selectionToJSX } from '@open-pencil/core/design-jsx'
import { useI18n, useSceneComputed } from '@open-pencil/vue'
import { highlightJSX } from '@/app/code/highlight'
import { useEditorStore } from '@/app/editor/active-store'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import AppTextButton from '@/components/ui/AppTextButton.vue'
@ -38,8 +37,7 @@ const jsxCode = useSceneComputed(() => {
const highlightedLines = computed(() => {
if (!jsxCode.value) return []
const grammar = Prism.languages.jsx ?? Prism.languages.javascript
return jsxCode.value.split('\n').map((line) => Prism.highlight(line, grammar, 'jsx'))
return jsxCode.value.split('\n').map(highlightJSX)
})
const { copy: copyRef, copied: copiedRef } = useClipboard({ copiedDuring: 2000 })

View file

@ -87,15 +87,11 @@ async function bindAssociatedFileOpen() {
}
onMounted(async () => {
try {
const mcp = await spawnMCPIfNeeded()
mcpCleanup.value = mcp?.disconnect ?? null
const tauri = isTauri()
if (import.meta.env.DEV || tauri) {
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
}
} catch (e) {
console.warn('[MCP]', e)
const mcp = await spawnMCPIfNeeded()
mcpCleanup.value = mcp?.disconnect ?? null
const tauri = isTauri()
if (import.meta.env.DEV || (tauri && mcp)) {
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
}
try {

View file

@ -0,0 +1,15 @@
import { describe, expect, test } from 'bun:test'
import { highlightJSX } from '@/app/code/highlight'
describe('code highlighting', () => {
test('highlights JavaScript and JSX syntax without a Prism global', () => {
const existingPrism = Reflect.get(globalThis, 'Prism')
const html = highlightJSX('const view = <Button disabled>{label}</Button>')
expect(html).toContain('<span class="token keyword">const</span>')
expect(html).toContain('<span class="token class-name">Button</span>')
expect(html).toContain('<span class="token attr-name">disabled</span>')
expect(Reflect.get(globalThis, 'Prism')).toBe(existingPrism)
})
})

View file

@ -1,6 +1,6 @@
import { afterEach, describe, expect, test, vi } from 'bun:test'
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
import { getAutomationAuthToken, spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
import { clearTauriMocks, installTauriMockWindow, mockTauriIPC } from '#tests/helpers/tauri/mocks'
@ -24,6 +24,81 @@ afterEach(async () => {
})
describe('Tauri MCP spawning', () => {
test('retains an early startup failure for MCP-dependent features', async () => {
installTauriMockWindow()
Object.assign(globalThis.window, { location: { origin: 'tauri://localhost' } })
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { platform: 'Win32', userAgent: 'Windows' }
})
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 404 }))
await mockTauriIPC((cmd, args) => {
if (cmd === 'mcp_executable_available') return true
if (cmd === 'plugin:path|resolve_directory') return '/mock/home'
if (cmd === 'plugin:fs|exists') return false
if (cmd === 'plugin:shell|spawn') {
const onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent
.onmessage
queueMicrotask(() => {
onEvent({
event: 'Stderr',
payload: Array.from(
new TextEncoder().encode('El sistema no puede encontrar el archivo especificado.')
)
})
onEvent({ event: 'Terminated', payload: { code: 1, signal: null } })
})
return 88
}
return null
})
expect(await spawnMCPIfNeeded()).toBeNull()
expect(getAutomationAuthToken()).rejects.toThrow('MCP server exited before startup completed')
})
test('retains unexpected spawn errors for MCP-dependent features', async () => {
installTauriMockWindow()
Object.assign(globalThis.window, { location: { origin: 'tauri://localhost' } })
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { platform: 'MacIntel', userAgent: 'Macintosh' }
})
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 404 }))
await mockTauriIPC((cmd) => {
if (cmd === 'mcp_executable_available') return true
if (cmd === 'plugin:path|resolve_directory') return '/mock/home'
if (cmd === 'plugin:fs|exists') return false
if (cmd === 'plugin:shell|spawn') throw new Error('shell permission denied')
return null
})
expect(await spawnMCPIfNeeded()).toBeNull()
expect(getAutomationAuthToken()).rejects.toThrow('shell permission denied')
})
test('retains a language-independent missing executable diagnostic', async () => {
installTauriMockWindow()
Object.assign(globalThis.window, { location: { origin: 'tauri://localhost' } })
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { platform: 'MacIntel', userAgent: 'Macintosh' }
})
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 404 }))
await mockTauriIPC((cmd) => {
if (cmd === 'plugin:path|resolve_directory') return '/mock/home'
if (cmd === 'plugin:fs|exists') return false
if (cmd === 'mcp_executable_available') return false
return null
})
expect(await spawnMCPIfNeeded()).toBeNull()
expect(getAutomationAuthToken()).rejects.toThrow('MCP automation is not installed')
})
test('spawns MCP server with shell plugin when health check is missing', async () => {
installTauriMockWindow()
Object.assign(globalThis.window, { location: { origin: 'tauri://localhost' } })
@ -51,6 +126,7 @@ describe('Tauri MCP spawning', () => {
const calls: Array<{ cmd: string; args: unknown }> = []
await mockTauriIPC((cmd, args) => {
calls.push({ cmd, args })
if (cmd === 'mcp_executable_available') return true
if (cmd === 'plugin:shell|spawn') {
expect(args).toMatchObject({
program: 'openpencil-mcp-http',
@ -68,9 +144,12 @@ describe('Tauri MCP spawning', () => {
onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent.onmessage
return 77
}
const pathArg = (args as { path?: string })?.path
if (cmd === 'plugin:fs|exists') {
return pathArg === DISCOVERY_PATH
}
if (cmd === 'plugin:fs|read_text_file') {
// Only return discovery data when reading the expected discovery path
const pathArg = (args as { path?: string })?.path
if (pathArg === DISCOVERY_PATH) return new TextEncoder().encode(DISCOVERY_JSON)
return null
}
@ -82,6 +161,7 @@ describe('Tauri MCP spawning', () => {
})
const handle = await spawnMCPIfNeeded()
await expect(getAutomationAuthToken()).resolves.toBe('discovery-token')
onEvent?.({ event: 'Stderr', payload: [119, 97, 114, 110] })
handle?.disconnect()
await Promise.resolve()