dsh-eda-mcp 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -1
- package/CONTRIBUTING.md +1 -1
- package/LICENSE +55 -55
- package/README.md +27 -4
- package/README.zh.md +26 -5
- package/docs/release.md +1 -1
- package/docs/tools.md +30 -0
- package/dsh-eda-bridge-extension-0.4.0.zip +0 -0
- package/jlc-extension/api-bridge.js +66 -12
- package/jlc-extension/extension.json +1 -1
- package/lib/bridge.mjs +680 -16
- package/lib/client.js +34 -1
- package/lib/index.js +24 -5
- package/package.json +5 -4
- package/src/bridge/eda-launch.ts +267 -0
- package/src/bridge/index.ts +582 -18
- package/src/client/EdaMcpSettingsCard.tsx +38 -1
- package/src/client/locales.ts +4 -0
- package/src/index.ts +29 -2
- package/src/routes.ts +3 -1
- package/test/eda-launch.test.ts +114 -0
- package/test/seams.test.ts +14 -0
- package/dsh-eda-bridge-extension.zip +0 -0
package/src/bridge/index.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// @ts-nocheck -- the bridge is authored as dependency-free JS and bundled by esbuild; runtime tests cover it.
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
4
|
+
import { existsSync, statSync } from 'node:fs'
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { writeFile } from 'node:fs/promises'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { basename, join } from 'node:path'
|
|
3
9
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
4
10
|
import type { Socket } from 'node:net'
|
|
5
11
|
import { createInterface } from 'node:readline'
|
|
12
|
+
import { buildLaunchArgs, buildLaunchScript, decodeRegistryOutput, resolveExecutable, type LaunchProbe } from './eda-launch.ts'
|
|
6
13
|
import {
|
|
7
14
|
ANNULUS_MIN_MM, BOARD_OUTLINE_WIDTH_MIL, MIN_TRACK_WIDTH_MIL, POUR_CLEARANCE_MIN_MIL,
|
|
8
15
|
POUR_CLEARANCE_RECOMMENDED_MIL, SILKSCREEN_HEIGHT_MIN_MM, SILKSCREEN_WIDTH_MIN_MM,
|
|
@@ -10,7 +17,7 @@ import {
|
|
|
10
17
|
assertNetName, checkNetName, checkTrackWidth, checkVia, classifyNet, milToMm, suggestTrackWidthMil,
|
|
11
18
|
} from './design-rules.ts'
|
|
12
19
|
|
|
13
|
-
const VERSION = '0.
|
|
20
|
+
const VERSION = '0.4.0'
|
|
14
21
|
const WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009)
|
|
15
22
|
const WS_HOST = '127.0.0.1'
|
|
16
23
|
const ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? '1') !== '0'
|
|
@@ -230,6 +237,21 @@ function objectSchema(properties, required = []) {
|
|
|
230
237
|
|
|
231
238
|
const TOOLS = [
|
|
232
239
|
{ name: 'eda_status', description: 'Get the dsh-eda-mcp bridge status and how many JLCEDA Pro bridge extensions are connected.', inputSchema: objectSchema({}) },
|
|
240
|
+
{ name: 'eda_list_projects', description: 'List project UUIDs in the workspace (dmt_Project.getAllProjectsUuid). resolveNames=true (default) also reads getProjectInfo per project so callers can match by friendly name.', inputSchema: objectSchema({ teamUuid: { type: 'string' }, folderUuid: { type: 'string' }, workspaceUuid: { type: 'string' }, resolveNames: { type: 'boolean' }, limit: { type: 'integer' } }) },
|
|
241
|
+
{ name: 'eda_get_project', description: 'Read the current project (dmt_Project.getCurrentProjectInfo): uuid, friendly name, folder/team and other brief fields.', inputSchema: objectSchema({}) },
|
|
242
|
+
{ name: 'eda_open_project', description: 'Open a project by uuid, or by friendly name when only a name is known (the name is resolved through getAllProjectsUuid + getProjectInfo, exact match first, then case-insensitive substring).', inputSchema: objectSchema({ uuid: { type: 'string' }, name: { type: 'string' } }) },
|
|
243
|
+
{ name: 'eda_create_project', description: 'Create a project (dmt_Project.createProject) and return its uuid; open=true (default false) also opens it. friendlyName is required; name and folder/team default to the app defaults.', inputSchema: objectSchema({ friendlyName: { type: 'string' }, name: { type: 'string' }, teamUuid: { type: 'string' }, folderUuid: { type: 'string' }, description: { type: 'string' }, open: { type: 'boolean' } }, ['friendlyName']) },
|
|
244
|
+
{ name: 'eda_get_workspace', description: 'Read the current workspace and team (dmt_Workspace.getCurrentWorkspaceInfo / dmt_Team.getCurrentTeamInfo). Needed before creating a project, which requires a team (and usually a folder) uuid.', inputSchema: objectSchema({}) },
|
|
245
|
+
{ name: 'eda_list_teams', description: 'List teams (dmt_Team.getAllTeamsInfo), optionally including the teams this account is involved in.', inputSchema: objectSchema({ involved: { type: 'boolean' } }) },
|
|
246
|
+
{ name: 'eda_list_folders', description: 'List folder uuids of one team (dmt_Folder.getAllFoldersUuid), optionally resolved through getFolderInfo.', inputSchema: objectSchema({ teamUuid: { type: 'string' }, resolveNames: { type: 'boolean', } }) },
|
|
247
|
+
{ name: 'eda_delete_project', description: 'Delete a project (dmt_Project.deleteProject) by uuid or friendly name.', inputSchema: objectSchema({ uuid: { type: 'string' }, name: { type: 'string' } }) },
|
|
248
|
+
{ name: 'eda_modify_project', description: 'Rename a project or change its description (dmt_Project.modifyProjectFriendlyName / modifyProjectDescription), addressed by uuid or current friendly name.', inputSchema: objectSchema({ uuid: { type: 'string' }, name: { type: 'string' }, friendlyName: { type: 'string' }, description: { type: 'string' } }) },
|
|
249
|
+
{ name: 'eda_copy_project', description: 'Copy a project (dmt_Project.copyProject), optionally into another team/folder and with a new friendly name; returns the new uuid.', inputSchema: objectSchema({ sourceUuid: { type: 'string' }, sourceName: { type: 'string' }, targetTeamUuid: { type: 'string' }, targetFolderUuid: { type: 'string' }, newFriendlyName: { type: 'string' }, newName: { type: 'string' } }) },
|
|
250
|
+
{ name: 'eda_close_document', description: 'Close one editor tab (dmt_EditorControl.closeDocument).', inputSchema: objectSchema({ tabId: { type: 'string' } }, ['tabId']) },
|
|
251
|
+
{ name: 'eda_export_project', description: 'Export a project file (sys_FileManager.getProjectFile / getProjectFileByProjectUuid). With outPath the bridge writes the bytes to disk and returns the path; without it the base64 payload is returned only when inline=true.', inputSchema: objectSchema({ uuid: { type: 'string' }, fileName: { type: 'string' }, fileType: { type: 'string', enum: ['epro', 'epro2'] }, outPath: { type: 'string' }, inline: { type: 'boolean' } }) },
|
|
252
|
+
{ name: 'eda_import_project', description: 'Import a local project file (.eprj2/.epro/...) into a team (sys_FileManager.importProjectByProjectFile with a saveTo target). teamUuid is required in local mode: without a target team the app imports nothing. Returns whether the import actually landed.', inputSchema: objectSchema({ path: { type: 'string' }, teamUuid: { type: 'string' }, folderUuid: { type: 'string' }, friendlyName: { type: 'string' }, fileType: { type: 'string' }, open: { type: 'boolean' } }, ['path']) },
|
|
253
|
+
{ name: 'eda_close', description: 'Close JLCEDA Pro gracefully (taskkill /T on Windows, SIGTERM elsewhere), then force-kill if it is still running after waitMs (default 8000). Returns the remaining process count.', inputSchema: objectSchema({ force: { type: 'boolean' }, waitMs: { type: 'integer' } }) },
|
|
254
|
+
{ name: 'eda_launch', description: 'Launch JLCEDA Pro (EasyEDA Pro), optionally opening a .eprj2/.eprj project, then wait until the bridge extension connects. The executable is resolved from DSH_EDA_EXECUTABLE/JLCEDA_EXECUTABLE, the registry handler the installer registered, conventional install directories, then PATH - so it works on any machine without configuration.', inputSchema: objectSchema({ project: { type: 'string' }, executable: { type: 'string' }, waitForExtension: { type: 'boolean' }, timeoutMs: { type: 'integer' } }) },
|
|
233
255
|
{ name: 'eda_get_page_info', description: 'Return the current schematic page, the A4 frame range, the title-block keep-out range, and the recommended safe placement areas. All coordinates are MCP units (0.01 inch).', inputSchema: objectSchema({ paper: { type: 'string' } }) },
|
|
234
256
|
{ name: 'eda_get_schematic_pages', description: 'List the schematic pages of the current schematic, or fetch one page by uuid.', inputSchema: objectSchema({ pageUuid: { type: 'string' } }) },
|
|
235
257
|
{ name: 'eda_search_components', description: 'Search JLCEDA library devices by keyword. Returns candidate components with uuid/libraryUuid needed by eda_place_component.', inputSchema: objectSchema({ keyword: { type: 'string' }, limit: { type: 'integer' } }, ['keyword']) },
|
|
@@ -391,6 +413,9 @@ class WsConnection {
|
|
|
391
413
|
this.socket.destroy()
|
|
392
414
|
}
|
|
393
415
|
handleData(chunk) {
|
|
416
|
+
// Liveness: a page that navigates away leaves a half-open socket that still
|
|
417
|
+
// looks ready, so every timestamp counts, not just pongs.
|
|
418
|
+
this.lastSeenAt = Date.now()
|
|
394
419
|
this.buffer = Buffer.concat([this.buffer, chunk])
|
|
395
420
|
for (;;) {
|
|
396
421
|
const frame = parseFrame(this.buffer)
|
|
@@ -449,16 +474,25 @@ async function pcbComponentIds(timeoutMs) {
|
|
|
449
474
|
}
|
|
450
475
|
let nextCallId = 1
|
|
451
476
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
477
|
+
/** A socket that has been silent this long is treated as dead even if it never closed. */
|
|
478
|
+
const CLIENT_STALE_MS = 60000
|
|
479
|
+
|
|
480
|
+
/** Ready clients, freshest first. Stale sockets are used only as a last resort. */
|
|
481
|
+
function pickClients() {
|
|
482
|
+
const now = Date.now()
|
|
483
|
+
const ready = [...clients].filter(client => client.ready)
|
|
484
|
+
const fresh = ready.filter(client => now - (client.lastSeenAt ?? 0) < CLIENT_STALE_MS)
|
|
485
|
+
const pool = fresh.length > 0 ? fresh : ready
|
|
486
|
+
return pool.sort((left, right) => (right.lastSeenAt ?? 0) - (left.lastSeenAt ?? 0))
|
|
455
487
|
}
|
|
456
488
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
489
|
+
/** Read-only calls may be repeated on another page; a write must never be. */
|
|
490
|
+
function isIdempotentRead(method) {
|
|
491
|
+
const fn = method.slice(method.lastIndexOf('.') + 1).toLowerCase()
|
|
492
|
+
return fn.startsWith('get') || fn.startsWith('is') || fn.startsWith('has') || fn.startsWith('list') || fn.startsWith('search')
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function sendToClient(client, method, args, timeoutMs) {
|
|
462
496
|
const id = nextCallId++
|
|
463
497
|
return new Promise((resolve, reject) => {
|
|
464
498
|
const timer = setTimeout(() => {
|
|
@@ -470,6 +504,27 @@ function callExtension(method, args, timeoutMs) {
|
|
|
470
504
|
})
|
|
471
505
|
}
|
|
472
506
|
|
|
507
|
+
async function callExtension(method, args, timeoutMs) {
|
|
508
|
+
const pool = pickClients()
|
|
509
|
+
if (pool.length === 0) {
|
|
510
|
+
throw new Error('JLCEDA bridge extension is not connected. Open JLCEDA Pro, import dsh-eda-bridge-extension-' + VERSION + '.zip, enable "Allow interactive with external", and keep a schematic or PCB page open.')
|
|
511
|
+
}
|
|
512
|
+
const deadline = Date.now() + timeoutMs
|
|
513
|
+
let lastError
|
|
514
|
+
for (const client of pool) {
|
|
515
|
+
const remaining = deadline - Date.now()
|
|
516
|
+
if (remaining <= 100) break
|
|
517
|
+
try {
|
|
518
|
+
return await sendToClient(client, method, args, remaining)
|
|
519
|
+
} catch (error) {
|
|
520
|
+
lastError = error
|
|
521
|
+
// A different error means the page answered; retrying would only repeat it.
|
|
522
|
+
if (!isIdempotentRead(method) || !/timed out/.test(String((error as Error)?.message))) throw error
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
throw lastError ?? new Error('JLCEDA bridge call timed out after ' + timeoutMs + 'ms: ' + method)
|
|
526
|
+
}
|
|
527
|
+
|
|
473
528
|
function asRecord(value) {
|
|
474
529
|
if (value === null || typeof value !== 'object') return {}
|
|
475
530
|
return value
|
|
@@ -773,6 +828,86 @@ async function auditPcb(args, timeoutMs) {
|
|
|
773
828
|
}
|
|
774
829
|
}
|
|
775
830
|
|
|
831
|
+
/**
|
|
832
|
+
* Real probe for `resolveExecutable`: filesystem existence plus `reg query`.
|
|
833
|
+
* Kept separate from the pure resolver so the resolution order stays testable.
|
|
834
|
+
*/
|
|
835
|
+
function launchProbe() {
|
|
836
|
+
const cache = new Map()
|
|
837
|
+
return {
|
|
838
|
+
platform: process.platform,
|
|
839
|
+
env: process.env,
|
|
840
|
+
exists(path) {
|
|
841
|
+
try { return statSync(path).isFile() } catch { return false }
|
|
842
|
+
},
|
|
843
|
+
registryQuery(key, valueName) {
|
|
844
|
+
if (process.platform !== 'win32') return undefined
|
|
845
|
+
// `undefined` (list every value) and `''` (the default value) are different
|
|
846
|
+
// queries and must not share a cache entry.
|
|
847
|
+
const cacheKey = key + '|' + (valueName === undefined ? 'LIST' : (valueName === '' ? 'DEFAULT' : 'V:' + valueName))
|
|
848
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey)
|
|
849
|
+
let output
|
|
850
|
+
try {
|
|
851
|
+
// `reg query <key>` alone prints only the default value, so listing a key
|
|
852
|
+
// (valueName undefined) needs `/s`; a single value uses `/v`, and the
|
|
853
|
+
// default value uses `/ve`.
|
|
854
|
+
const extra = valueName === undefined ? ['/s'] : (valueName === '' ? ['/ve'] : ['/v', valueName])
|
|
855
|
+
const raw = execFileSync('reg', ['query', key, ...extra], {
|
|
856
|
+
timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
|
|
857
|
+
})
|
|
858
|
+
output = decodeRegistryOutput(raw)
|
|
859
|
+
} catch {
|
|
860
|
+
output = undefined
|
|
861
|
+
}
|
|
862
|
+
cache.set(cacheKey, output)
|
|
863
|
+
return output
|
|
864
|
+
},
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** How many processes currently run that image name (Windows tasklist). */
|
|
869
|
+
function processCountByName(imageName) {
|
|
870
|
+
if (process.platform !== 'win32') {
|
|
871
|
+
// pgrep exits non-zero when nothing matches, which is the "closed" case.
|
|
872
|
+
try {
|
|
873
|
+
const out = execFileSync('pgrep', ['-f', imageName], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
874
|
+
return out.split(/\r?\n/).filter(line => line.trim() !== '').length
|
|
875
|
+
} catch {
|
|
876
|
+
return 0
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
const out = execFileSync('tasklist', ['/FI', 'IMAGENAME eq ' + imageName, '/FO', 'CSV', '/NH'], {
|
|
881
|
+
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
882
|
+
})
|
|
883
|
+
return out.split(/\r?\n/).filter(line => line.trim().startsWith('"')).length
|
|
884
|
+
} catch {
|
|
885
|
+
return 0
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Ask the app to close (/T closes the renderer children too); force kills it. */
|
|
890
|
+
function terminateByName(imageName, force) {
|
|
891
|
+
if (process.platform === 'win32') {
|
|
892
|
+
execFileSync('taskkill', ['/IM', imageName, '/T'].concat(force ? ['/F'] : []), {
|
|
893
|
+
timeout: 10000, stdio: ['ignore', 'ignore', 'ignore'],
|
|
894
|
+
})
|
|
895
|
+
return
|
|
896
|
+
}
|
|
897
|
+
execFileSync('pkill', [force ? '-9' : '-TERM', '-f', imageName], { timeout: 10000, stdio: ['ignore', 'ignore', 'ignore'] })
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Wait (bounded) until at least one EDA extension reports ready. */
|
|
901
|
+
async function waitForExtensionConnection(timeoutMs) {
|
|
902
|
+
const started = Date.now()
|
|
903
|
+
const readyCount = () => pickClients().length
|
|
904
|
+
while (Date.now() - started < timeoutMs) {
|
|
905
|
+
if (readyCount() > 0) return { connected: true, clients: readyCount(), waitedMs: Date.now() - started }
|
|
906
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
907
|
+
}
|
|
908
|
+
return { connected: false, clients: readyCount(), waitedMs: Date.now() - started }
|
|
909
|
+
}
|
|
910
|
+
|
|
776
911
|
async function clearRoutingBatched(args, type, timeoutMs) {
|
|
777
912
|
if (type === 'connection') throw new Error('eda_pcb_clear_routing: type=connection needs the EDA user interface; use type=all/net with mode=batched')
|
|
778
913
|
const net = args.net === undefined ? undefined : String(args.net)
|
|
@@ -877,28 +1012,457 @@ function pickDefined(source, keys) {
|
|
|
877
1012
|
return out
|
|
878
1013
|
}
|
|
879
1014
|
|
|
1015
|
+
/** Resolve a project uuid from { uuid } or { name } (exact match first, then case-insensitive substring). */
|
|
1016
|
+
async function resolveProjectUuid(args, timeoutMs) {
|
|
1017
|
+
const direct = args.uuid === undefined ? '' : String(args.uuid).trim()
|
|
1018
|
+
if (direct !== '') return { uuid: direct, matched: null }
|
|
1019
|
+
const name = args.name === undefined ? '' : String(args.name).trim()
|
|
1020
|
+
if (name === '') throw new Error('provide uuid or name')
|
|
1021
|
+
const unfiltered = await callExtension('dmt_Project.getAllProjectsUuid', [], toolTimeout(args, timeoutMs))
|
|
1022
|
+
const candidates = Array.isArray(unfiltered) ? unfiltered.map(String) : []
|
|
1023
|
+
// Local directories are modelled as teams, so an unfiltered listing is empty;
|
|
1024
|
+
// collect every team's projects before giving up on the name.
|
|
1025
|
+
if (candidates.length === 0) {
|
|
1026
|
+
const teams = await callExtension('dmt_Team.getAllTeamsInfo', [], toolTimeout(args, timeoutMs)).catch(() => [])
|
|
1027
|
+
const seen = new Set()
|
|
1028
|
+
for (const team of Array.isArray(teams) ? teams : []) {
|
|
1029
|
+
const teamUuid = String(pickValue(asRecord(team), 'uuid', 'Uuid') ?? '')
|
|
1030
|
+
if (teamUuid === '') continue
|
|
1031
|
+
const owned = await callExtension('dmt_Project.getAllProjectsUuid', [teamUuid], toolTimeout(args, timeoutMs)).catch(() => [])
|
|
1032
|
+
for (const uuid of Array.isArray(owned) ? owned.map(String) : []) {
|
|
1033
|
+
if (seen.has(uuid)) continue
|
|
1034
|
+
seen.add(uuid)
|
|
1035
|
+
candidates.push(uuid)
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const resolved = []
|
|
1040
|
+
for (const candidate of candidates) {
|
|
1041
|
+
try {
|
|
1042
|
+
const info = asRecord(await callExtension('dmt_Project.getProjectInfo', [candidate], toolTimeout(args, timeoutMs)))
|
|
1043
|
+
resolved.push({ uuid: candidate, friendly: String(pickValue(info, 'FriendlyName', 'friendlyName', 'Name', 'name') ?? '') })
|
|
1044
|
+
} catch {
|
|
1045
|
+
resolved.push({ uuid: candidate, friendly: '' })
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const matched = resolved.find(item => item.friendly === name)
|
|
1049
|
+
?? resolved.find(item => item.friendly.toLowerCase().includes(name.toLowerCase()))
|
|
1050
|
+
?? null
|
|
1051
|
+
if (matched === null) {
|
|
1052
|
+
throw new Error('no project matches "' + name + '"; known names: ' +
|
|
1053
|
+
resolved.map(item => item.friendly || item.uuid).slice(0, 20).join(', '))
|
|
1054
|
+
}
|
|
1055
|
+
return { uuid: matched.uuid, matched }
|
|
1056
|
+
}
|
|
1057
|
+
|
|
880
1058
|
async function executeTool(name, rawArgs, timeoutMs) {
|
|
881
1059
|
const args = asRecord(rawArgs)
|
|
882
1060
|
const tool = PCB_TOOL_ALIASES[name] ?? name
|
|
883
1061
|
|
|
884
1062
|
switch (tool) {
|
|
885
1063
|
case 'eda_status': {
|
|
886
|
-
const ready =
|
|
887
|
-
|
|
1064
|
+
const ready = pickClients()
|
|
1065
|
+
// Every EDA page keeps its own socket, so several clients can disagree: list
|
|
1066
|
+
// them all and judge the bridge as outdated when ANY of them is not on VERSION.
|
|
1067
|
+
const versions = ready.map(client => (typeof client.extensionVersion === 'string' ? client.extensionVersion : null))
|
|
1068
|
+
const extensionVersion = versions.find(value => typeof value === 'string') ?? null
|
|
1069
|
+
const stale = versions.filter(value => value !== VERSION)
|
|
888
1070
|
return {
|
|
889
1071
|
ok: true, bridgeVersion: VERSION, wsPort: WS_PORT, allowRawApi: ALLOW_RAW_API,
|
|
890
1072
|
connectedClients: ready.length,
|
|
891
1073
|
extensionVersion: extensionVersion ?? null,
|
|
892
|
-
|
|
1074
|
+
extensionVersions: versions,
|
|
1075
|
+
versionMismatch: stale.length > 0,
|
|
893
1076
|
mcpUnits: '0.01 inch', defaultPaper: DEFAULT_PAPER,
|
|
894
1077
|
hint: ready.length === 0
|
|
895
|
-
? 'No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension.zip.'
|
|
896
|
-
: (
|
|
897
|
-
? '
|
|
1078
|
+
? 'No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension-' + VERSION + '.zip.'
|
|
1079
|
+
: (stale.length > 0
|
|
1080
|
+
? 'Connected EDA extension ' + stale.join(', ') + ' does not match bridge ' + VERSION +
|
|
1081
|
+
'. In JLCEDA Pro remove the OLD dsh-eda-bridge extension first, import dsh-eda-bridge-extension-' + VERSION + '.zip again, then restart the app; a simple reload keeps the old copy.'
|
|
898
1082
|
: 'ok'),
|
|
899
1083
|
}
|
|
900
1084
|
}
|
|
901
1085
|
|
|
1086
|
+
case 'eda_close_document':
|
|
1087
|
+
return await callExtension('dmt_EditorControl.closeDocument', [String(args.tabId ?? '')], timeoutMs)
|
|
1088
|
+
|
|
1089
|
+
case 'eda_export_project': {
|
|
1090
|
+
const result = await callExtension('dsh.internal.exportProjectFile', [
|
|
1091
|
+
args.uuid === undefined ? null : String(args.uuid),
|
|
1092
|
+
args.fileName === undefined ? undefined : String(args.fileName),
|
|
1093
|
+
undefined,
|
|
1094
|
+
args.fileType === undefined ? undefined : String(args.fileType),
|
|
1095
|
+
], toolTimeout(args, timeoutMs))
|
|
1096
|
+
const base64 = typeof result?.base64 === 'string' ? result.base64 : ''
|
|
1097
|
+
const outPath = args.outPath === undefined ? '' : String(args.outPath).trim()
|
|
1098
|
+
if (outPath !== '') {
|
|
1099
|
+
await writeFile(outPath, Buffer.from(base64, 'base64'))
|
|
1100
|
+
return { ok: true, written: outPath, name: result?.name ?? null, size: base64 === '' ? 0 : Buffer.from(base64, 'base64').length }
|
|
1101
|
+
}
|
|
1102
|
+
if (args.inline !== true) {
|
|
1103
|
+
return { ok: true, name: result?.name ?? null, base64Length: base64.length, hint: 'Pass outPath to write the file, or inline=true to receive the base64 payload.' }
|
|
1104
|
+
}
|
|
1105
|
+
return { ok: true, name: result?.name ?? null, base64 }
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
case 'eda_import_project': {
|
|
1109
|
+
const path = String(args.path ?? '').trim()
|
|
1110
|
+
if (path === '') throw new Error('eda_import_project requires path')
|
|
1111
|
+
let bytes
|
|
1112
|
+
try { bytes = await readFile(path) } catch {
|
|
1113
|
+
throw new Error('eda_import_project: cannot read "' + path + '"')
|
|
1114
|
+
}
|
|
1115
|
+
// importProjectByProjectFile only lands anything when the target team is given
|
|
1116
|
+
// (saveTo); without it the app answers undefined and nothing is imported.
|
|
1117
|
+
let teamUuid = args.teamUuid === undefined ? '' : String(args.teamUuid).trim()
|
|
1118
|
+
if (teamUuid === '') {
|
|
1119
|
+
const current = await callExtension('dmt_Team.getCurrentTeamInfo', [], timeoutMs).catch(() => undefined)
|
|
1120
|
+
teamUuid = String(pickValue(asRecord(current), 'uuid', 'Uuid') ?? '')
|
|
1121
|
+
}
|
|
1122
|
+
if (teamUuid === '') {
|
|
1123
|
+
throw new Error('eda_import_project needs teamUuid: the app imports nothing without a target team. Use a team uuid from eda_list_teams (local mode reports project directories) or the teamUuid of an existing project (eda_list_projects).')
|
|
1124
|
+
}
|
|
1125
|
+
const saveTo: Record<string, unknown> = { operation: 'New Project', newProjectOwnerTeamUuid: teamUuid }
|
|
1126
|
+
if (args.folderUuid !== undefined) saveTo.newProjectOwnerFolderUuid = String(args.folderUuid)
|
|
1127
|
+
if (args.friendlyName !== undefined) saveTo.newProjectFriendlyName = String(args.friendlyName)
|
|
1128
|
+
const imported = await callExtension('dsh.internal.importProjectFile', [
|
|
1129
|
+
bytes.toString('base64'),
|
|
1130
|
+
basename(path),
|
|
1131
|
+
args.fileType === undefined ? undefined : String(args.fileType),
|
|
1132
|
+
saveTo,
|
|
1133
|
+
], toolTimeout(args, timeoutMs))
|
|
1134
|
+
const raw = imported?.result ?? imported
|
|
1135
|
+
const uuid = typeof raw === 'string' ? raw : (typeof raw?.uuid === 'string' ? raw.uuid : undefined)
|
|
1136
|
+
const landed = raw !== null && raw !== undefined
|
|
1137
|
+
let opened = false
|
|
1138
|
+
if (args.open === true && uuid !== undefined) {
|
|
1139
|
+
try { opened = (await callExtension('dmt_Project.openProject', [uuid], timeoutMs)) !== false } catch { opened = false }
|
|
1140
|
+
}
|
|
1141
|
+
return {
|
|
1142
|
+
ok: landed,
|
|
1143
|
+
imported: landed,
|
|
1144
|
+
name: basename(path),
|
|
1145
|
+
size: bytes.length,
|
|
1146
|
+
teamUuid,
|
|
1147
|
+
uuid: uuid ?? null,
|
|
1148
|
+
opened,
|
|
1149
|
+
result: raw ?? null,
|
|
1150
|
+
hint: landed ? null : 'The app returned nothing: the import did not create a project. That means this build ignores the saveTo target (same limitation as createProject), so import cannot be used to create projects here.',
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
case 'eda_close': {
|
|
1155
|
+
const resolved = resolveExecutable(launchProbe(), {
|
|
1156
|
+
executable: args.executable === undefined ? undefined : String(args.executable),
|
|
1157
|
+
})
|
|
1158
|
+
const imageName = resolved === undefined ? 'lceda-pro.exe' : basename(resolved.executable)
|
|
1159
|
+
const before = processCountByName(imageName)
|
|
1160
|
+
const waitMs = Number.isFinite(Number(args.waitMs)) ? Math.max(1000, Number(args.waitMs)) : 8000
|
|
1161
|
+
let error = null
|
|
1162
|
+
try {
|
|
1163
|
+
terminateByName(imageName, false)
|
|
1164
|
+
} catch (failure) {
|
|
1165
|
+
error = failure instanceof Error ? failure.message : String(failure)
|
|
1166
|
+
}
|
|
1167
|
+
const started = Date.now()
|
|
1168
|
+
let remaining = processCountByName(imageName)
|
|
1169
|
+
while (remaining > 0 && Date.now() - started < waitMs) {
|
|
1170
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
1171
|
+
remaining = processCountByName(imageName)
|
|
1172
|
+
}
|
|
1173
|
+
let forced = false
|
|
1174
|
+
if (remaining > 0) {
|
|
1175
|
+
try {
|
|
1176
|
+
terminateByName(imageName, true)
|
|
1177
|
+
forced = true
|
|
1178
|
+
await new Promise(resolve => setTimeout(resolve, 1500))
|
|
1179
|
+
remaining = processCountByName(imageName)
|
|
1180
|
+
} catch (failure) {
|
|
1181
|
+
error = error ?? (failure instanceof Error ? failure.message : String(failure))
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const connected = pickClients().length
|
|
1185
|
+
return {
|
|
1186
|
+
ok: remaining <= 0,
|
|
1187
|
+
closed: remaining <= 0,
|
|
1188
|
+
forced,
|
|
1189
|
+
imageName,
|
|
1190
|
+
processesBefore: before,
|
|
1191
|
+
processesAfter: remaining,
|
|
1192
|
+
connectedClients: connected,
|
|
1193
|
+
waitedMs: Date.now() - started,
|
|
1194
|
+
error,
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
case 'eda_list_projects': {
|
|
1199
|
+
// Omit the filters entirely when they are not given: the official API treats a
|
|
1200
|
+
// literal null as a filter value and the call does not resolve.
|
|
1201
|
+
const filters = []
|
|
1202
|
+
if (args.teamUuid !== undefined) filters.push(String(args.teamUuid))
|
|
1203
|
+
if (args.folderUuid !== undefined || args.workspaceUuid !== undefined) filters.push(args.folderUuid === undefined ? null : String(args.folderUuid))
|
|
1204
|
+
if (args.workspaceUuid !== undefined) filters.push(String(args.workspaceUuid))
|
|
1205
|
+
let uuids = await callExtension('dmt_Project.getAllProjectsUuid', filters, toolTimeout(args, timeoutMs))
|
|
1206
|
+
let list = Array.isArray(uuids) ? uuids.map(String) : []
|
|
1207
|
+
let aggregated = false
|
|
1208
|
+
// In semi-offline mode the app models each local project directory as a
|
|
1209
|
+
// "team", so an unfiltered call returns nothing; walk the teams instead.
|
|
1210
|
+
if (list.length === 0 && filters.length === 0) {
|
|
1211
|
+
const teams = await callExtension('dmt_Team.getAllTeamsInfo', [], toolTimeout(args, timeoutMs)).catch(() => [])
|
|
1212
|
+
const seen = new Set()
|
|
1213
|
+
for (const team of Array.isArray(teams) ? teams : []) {
|
|
1214
|
+
const teamUuid = String(pickValue(asRecord(team), 'uuid', 'Uuid') ?? '')
|
|
1215
|
+
if (teamUuid === '') continue
|
|
1216
|
+
const owned = await callExtension('dmt_Project.getAllProjectsUuid', [teamUuid], toolTimeout(args, timeoutMs)).catch(() => [])
|
|
1217
|
+
for (const uuid of Array.isArray(owned) ? owned.map(String) : []) {
|
|
1218
|
+
if (seen.has(uuid)) continue
|
|
1219
|
+
seen.add(uuid)
|
|
1220
|
+
list.push(uuid)
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
aggregated = true
|
|
1224
|
+
}
|
|
1225
|
+
if (args.resolveNames === false) return { count: list.length, uuids: list, aggregated }
|
|
1226
|
+
const limit = Number.isFinite(Number(args.limit)) ? Math.max(1, Number(args.limit)) : list.length
|
|
1227
|
+
const projects = []
|
|
1228
|
+
for (const uuid of list.slice(0, limit)) {
|
|
1229
|
+
try {
|
|
1230
|
+
// Verbatim: project records carry unstable keys and summarising them
|
|
1231
|
+
// produced empty objects.
|
|
1232
|
+
projects.push(await callExtension('dmt_Project.getProjectInfo', [uuid], toolTimeout(args, timeoutMs)))
|
|
1233
|
+
} catch {
|
|
1234
|
+
projects.push({ uuid })
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return { count: list.length, resolved: projects.length, aggregated, projects }
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
case 'eda_get_project': {
|
|
1241
|
+
const project = await callExtension('dmt_Project.getCurrentProjectInfo', [], timeoutMs)
|
|
1242
|
+
return project === undefined || project === null
|
|
1243
|
+
? { project: null, hint: 'No project is open: JLCEDA Pro is on the HOME page. Use eda_list_projects + eda_open_project, or open one in the app.' }
|
|
1244
|
+
: project
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
case 'eda_open_project': {
|
|
1248
|
+
const resolvedProject = await resolveProjectUuid(args, timeoutMs)
|
|
1249
|
+
const uuid = resolvedProject.uuid
|
|
1250
|
+
const opened = await callExtension('dmt_Project.openProject', [uuid], toolTimeout(args, timeoutMs))
|
|
1251
|
+
const current = await callExtension('dmt_Project.getCurrentProjectInfo', [], timeoutMs).catch(() => undefined)
|
|
1252
|
+
return { opened: opened !== false, uuid, matched: resolvedProject.matched, current: current === undefined ? null : summarizePrimitive(current) }
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
case 'eda_get_workspace': {
|
|
1256
|
+
const workspace = await callExtension('dmt_Workspace.getCurrentWorkspaceInfo', [], timeoutMs).catch(() => undefined)
|
|
1257
|
+
const team = await callExtension('dmt_Team.getCurrentTeamInfo', [], timeoutMs).catch(() => undefined)
|
|
1258
|
+
// Team/workspace payloads are small and their field names are unstable, so
|
|
1259
|
+
// they are returned verbatim: summarising them produced empty objects.
|
|
1260
|
+
return {
|
|
1261
|
+
workspace: workspace === undefined ? null : workspace,
|
|
1262
|
+
team: team === undefined ? null : team,
|
|
1263
|
+
hint: workspace === undefined && team === undefined
|
|
1264
|
+
? 'No workspace/team is available: start JLCEDA Pro and sign in to a JLC account. Workspace projects cannot be created or listed without one.'
|
|
1265
|
+
: 'ok',
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
case 'eda_list_teams': {
|
|
1270
|
+
const teams = await callExtension(args.involved === true ? 'dmt_Team.getAllInvolvedTeamInfo' : 'dmt_Team.getAllTeamsInfo', [], toolTimeout(args, timeoutMs))
|
|
1271
|
+
const list = Array.isArray(teams) ? teams : []
|
|
1272
|
+
return { count: list.length, teams: list }
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
case 'eda_list_folders': {
|
|
1276
|
+
const teamUuid = args.teamUuid === undefined ? '' : String(args.teamUuid).trim()
|
|
1277
|
+
let resolvedTeam = teamUuid
|
|
1278
|
+
if (resolvedTeam === '') {
|
|
1279
|
+
const current = await callExtension('dmt_Team.getCurrentTeamInfo', [], timeoutMs).catch(() => undefined)
|
|
1280
|
+
resolvedTeam = String(pickValue(asRecord(current), 'Uuid', 'uuid') ?? '')
|
|
1281
|
+
}
|
|
1282
|
+
if (resolvedTeam === '') throw new Error('eda_list_folders needs teamUuid (eda_list_teams shows them)')
|
|
1283
|
+
// The app throws a bare TypeError when the team is not a real uuid (the local
|
|
1284
|
+
// directories reported by eda_list_teams are paths), so say what to pass.
|
|
1285
|
+
let uuids
|
|
1286
|
+
try {
|
|
1287
|
+
uuids = await callExtension('dmt_Folder.getAllFoldersUuid', [resolvedTeam], toolTimeout(args, timeoutMs))
|
|
1288
|
+
} catch (failure) {
|
|
1289
|
+
throw new Error('eda_list_folders: the app rejected team "' + resolvedTeam + '" (' +
|
|
1290
|
+
(failure instanceof Error ? failure.message : String(failure)) +
|
|
1291
|
+
'); pass the uuid from a project record (eda_list_projects) or eda_list_teams')
|
|
1292
|
+
}
|
|
1293
|
+
const list = Array.isArray(uuids) ? uuids.map(String) : []
|
|
1294
|
+
if (args.resolveNames === false) return { teamUuid: resolvedTeam, count: list.length, uuids: list }
|
|
1295
|
+
const folders = []
|
|
1296
|
+
for (const folderUuid of list.slice(0, 20)) {
|
|
1297
|
+
try {
|
|
1298
|
+
folders.push({ folderUuid, info: await callExtension('dmt_Folder.getFolderInfo', [resolvedTeam, folderUuid], timeoutMs) })
|
|
1299
|
+
} catch {
|
|
1300
|
+
folders.push({ folderUuid, info: null })
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return { teamUuid: resolvedTeam, count: list.length, folders }
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
case 'eda_delete_project': {
|
|
1307
|
+
const resolvedProject = await resolveProjectUuid(args, timeoutMs)
|
|
1308
|
+
const deleted = await callExtension('dmt_Project.deleteProject', [resolvedProject.uuid], toolTimeout(args, timeoutMs))
|
|
1309
|
+
const ok = deleted !== false
|
|
1310
|
+
return {
|
|
1311
|
+
deleted: ok,
|
|
1312
|
+
uuid: resolvedProject.uuid,
|
|
1313
|
+
matched: resolvedProject.matched,
|
|
1314
|
+
hint: ok ? null : 'The app returned false and the project still exists: this build refuses deleteProject for local projects (verified with the project open and after switching away), so remove it in the app.',
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
case 'eda_modify_project': {
|
|
1319
|
+
const resolvedProject = await resolveProjectUuid(args, timeoutMs)
|
|
1320
|
+
const changes = []
|
|
1321
|
+
if (args.friendlyName !== undefined) {
|
|
1322
|
+
changes.push(['friendlyName', await callExtension('dmt_Project.modifyProjectFriendlyName', [resolvedProject.uuid, String(args.friendlyName)], timeoutMs)])
|
|
1323
|
+
}
|
|
1324
|
+
if (args.description !== undefined) {
|
|
1325
|
+
changes.push(['description', await callExtension('dmt_Project.modifyProjectDescription', [resolvedProject.uuid, String(args.description)], timeoutMs)])
|
|
1326
|
+
}
|
|
1327
|
+
if (changes.length === 0) throw new Error('eda_modify_project requires friendlyName or description')
|
|
1328
|
+
return {
|
|
1329
|
+
ok: changes.every(([, value]) => value !== false),
|
|
1330
|
+
uuid: resolvedProject.uuid,
|
|
1331
|
+
matched: resolvedProject.matched,
|
|
1332
|
+
applied: changes.map(([field, value]) => ({ field, ok: value !== false })),
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
case 'eda_copy_project': {
|
|
1337
|
+
const source = await resolveProjectUuid({
|
|
1338
|
+
uuid: args.sourceUuid, name: args.sourceName,
|
|
1339
|
+
}, timeoutMs)
|
|
1340
|
+
const uuid = await callExtension('dmt_Project.copyProject', [
|
|
1341
|
+
source.uuid,
|
|
1342
|
+
args.targetTeamUuid === undefined ? undefined : String(args.targetTeamUuid),
|
|
1343
|
+
args.targetFolderUuid === undefined ? undefined : String(args.targetFolderUuid),
|
|
1344
|
+
args.newFriendlyName === undefined ? undefined : String(args.newFriendlyName),
|
|
1345
|
+
args.newName === undefined ? undefined : String(args.newName),
|
|
1346
|
+
], toolTimeout(args, timeoutMs))
|
|
1347
|
+
return { copied: typeof uuid === 'string' && uuid !== '', sourceUuid: source.uuid, uuid: uuid ?? null }
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
case 'eda_create_project': {
|
|
1351
|
+
const friendlyName = String(args.friendlyName ?? '').trim()
|
|
1352
|
+
if (friendlyName === '') throw new Error('eda_create_project requires friendlyName')
|
|
1353
|
+
// createProject needs an owning team: default to the current one instead of
|
|
1354
|
+
// passing undefined, which the app answers with an empty uuid.
|
|
1355
|
+
let teamUuid = args.teamUuid === undefined ? '' : String(args.teamUuid).trim()
|
|
1356
|
+
let teamSource = 'argument'
|
|
1357
|
+
if (teamUuid === '') {
|
|
1358
|
+
const current = await callExtension('dmt_Team.getCurrentTeamInfo', [], timeoutMs).catch(() => undefined)
|
|
1359
|
+
teamUuid = String(pickValue(asRecord(current), 'Uuid', 'uuid') ?? '')
|
|
1360
|
+
teamSource = teamUuid === '' ? 'unavailable' : 'current-team'
|
|
1361
|
+
}
|
|
1362
|
+
if (teamUuid === '') {
|
|
1363
|
+
throw new Error('eda_create_project: no team is available (sign in to a JLC account in JLCEDA Pro, or pass teamUuid from eda_list_teams)')
|
|
1364
|
+
}
|
|
1365
|
+
const folderUuid = args.folderUuid === undefined ? '' : String(args.folderUuid).trim()
|
|
1366
|
+
const uuid = await callExtension('dmt_Project.createProject', [
|
|
1367
|
+
friendlyName,
|
|
1368
|
+
args.name === undefined ? undefined : String(args.name),
|
|
1369
|
+
teamUuid,
|
|
1370
|
+
folderUuid === '' ? undefined : folderUuid,
|
|
1371
|
+
args.description === undefined ? undefined : String(args.description),
|
|
1372
|
+
], toolTimeout(args, timeoutMs))
|
|
1373
|
+
if (typeof uuid !== 'string' || uuid === '') {
|
|
1374
|
+
throw new Error('eda_create_project: the app returned no project uuid (team ' + teamUuid + ' from ' + teamSource + '; the app may have shown a dialog or rejected the name)')
|
|
1375
|
+
}
|
|
1376
|
+
let opened = false
|
|
1377
|
+
if (args.open === true) {
|
|
1378
|
+
try {
|
|
1379
|
+
opened = (await callExtension('dmt_Project.openProject', [uuid], timeoutMs)) !== false
|
|
1380
|
+
} catch {
|
|
1381
|
+
opened = false
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return { created: true, uuid, friendlyName, teamUuid, teamSource, folderUuid: folderUuid === '' ? null : folderUuid, opened }
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
case 'eda_launch': {
|
|
1388
|
+
const probe = launchProbe()
|
|
1389
|
+
const resolved = resolveExecutable(probe, {
|
|
1390
|
+
executable: args.executable === undefined ? undefined : String(args.executable),
|
|
1391
|
+
project: args.project === undefined ? undefined : String(args.project),
|
|
1392
|
+
})
|
|
1393
|
+
if (resolved === undefined) {
|
|
1394
|
+
throw new Error([
|
|
1395
|
+
'eda_launch: could not find the JLCEDA Pro executable on this machine.',
|
|
1396
|
+
'Set `DSH_EDA_EXECUTABLE` (or the plugin setting edaExecutable) to the full path of lceda-pro.exe.',
|
|
1397
|
+
'Searched: DSH_EDA_EXECUTABLE / JLCEDA_EXECUTABLE, the registry handler registered for .eprj2/.eprj,',
|
|
1398
|
+
'conventional install directories under Program Files / LOCALAPPDATA / the system drive, and PATH.',
|
|
1399
|
+
].join(' '))
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const alreadyConnected = pickClients().length
|
|
1403
|
+
const project = args.project === undefined ? undefined : String(args.project)
|
|
1404
|
+
if (project !== undefined && project !== '') {
|
|
1405
|
+
try { if (!statSync(project).isFile()) throw new Error('not a file') } catch {
|
|
1406
|
+
throw new Error('eda_launch: project "' + project + '" does not exist or is not a file')
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// On Windows a direct spawn (and even ShellExecute) makes lceda-pro.exe start
|
|
1411
|
+
// and quit immediately, while the same executable works when the desktop
|
|
1412
|
+
// shell launches it. Explorer runs in the interactive desktop, so the launch
|
|
1413
|
+
// is handed to it through a tiny script that starts the app (with the project
|
|
1414
|
+
// when given). Other platforms spawn the resolved command directly.
|
|
1415
|
+
const plan = buildLaunchArgs(process.platform, resolved.executable, project)
|
|
1416
|
+
let child
|
|
1417
|
+
let via = 'direct'
|
|
1418
|
+
if (process.platform === 'win32') {
|
|
1419
|
+
try {
|
|
1420
|
+
const scriptPath = join(tmpdir(), 'dsh-eda-launch-' + process.pid + '-' + Date.now() + '.cmd')
|
|
1421
|
+
await writeFile(scriptPath, buildLaunchScript(resolved.executable, project), 'utf8')
|
|
1422
|
+
child = spawn(process.env.SystemRoot ? join(process.env.SystemRoot, 'explorer.exe') : 'explorer.exe', [scriptPath], {
|
|
1423
|
+
detached: true, stdio: 'ignore', windowsHide: false,
|
|
1424
|
+
})
|
|
1425
|
+
via = 'explorer'
|
|
1426
|
+
} catch {
|
|
1427
|
+
child = spawn(plan.command, plan.args, { detached: true, stdio: 'ignore', windowsHide: false })
|
|
1428
|
+
via = 'direct'
|
|
1429
|
+
}
|
|
1430
|
+
} else {
|
|
1431
|
+
child = spawn(plan.command, plan.args, { detached: true, stdio: 'ignore', windowsHide: false })
|
|
1432
|
+
}
|
|
1433
|
+
child.unref()
|
|
1434
|
+
|
|
1435
|
+
const shouldWait = args.waitForExtension !== false
|
|
1436
|
+
const timeoutMs = Number.isFinite(Number(args.timeoutMs)) ? Math.max(1000, Number(args.timeoutMs)) : 30000
|
|
1437
|
+
const waited = shouldWait
|
|
1438
|
+
? await waitForExtensionConnection(timeoutMs)
|
|
1439
|
+
: { connected: alreadyConnected > 0, clients: alreadyConnected, waitedMs: 0 }
|
|
1440
|
+
|
|
1441
|
+
return {
|
|
1442
|
+
ok: true,
|
|
1443
|
+
launched: true,
|
|
1444
|
+
pid: child.pid ?? null,
|
|
1445
|
+
via,
|
|
1446
|
+
executable: resolved.executable,
|
|
1447
|
+
source: resolved.source,
|
|
1448
|
+
project: project ?? null,
|
|
1449
|
+
// Verified on JLCEDA Pro 3.2.186: the app starts but ignores the project
|
|
1450
|
+
// argument passed on the command line (it opens on HOME), so the caller must
|
|
1451
|
+
// not assume the project was opened.
|
|
1452
|
+
projectOpenedByArgument: false,
|
|
1453
|
+
projectNote: project === undefined || project === ''
|
|
1454
|
+
? null
|
|
1455
|
+
: 'The app was started with the project path, but this JLCEDA Pro build ignores it and opens on HOME; open the project from the app itself.',
|
|
1456
|
+
target: { command: plan.command, args: plan.args },
|
|
1457
|
+
connected: waited.connected,
|
|
1458
|
+
connectedClients: waited.clients,
|
|
1459
|
+
waitedMs: waited.waitedMs,
|
|
1460
|
+
hint: waited.connected
|
|
1461
|
+
? 'JLCEDA is connected; the eda_* tools can be used now.'
|
|
1462
|
+
: 'The app was started but no EDA extension connected yet. In JLCEDA Pro: 扩展 → DSH MCP → 连接 (and enable “允许外部交互”); the extension must be imported once from dsh-eda-bridge-extension-' + VERSION + '.zip.',
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
902
1466
|
case 'eda_get_page_info': {
|
|
903
1467
|
const paper = paperOf(args.paper)
|
|
904
1468
|
let page
|
|
@@ -1418,7 +1982,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1418
1982
|
triggered: true, backgroundUnavailable: true, importResult: result,
|
|
1419
1983
|
componentsBefore: pcbComponentBaseline.length, componentsAfter: after.length,
|
|
1420
1984
|
confirmedYet: after.length > pcbComponentBaseline.length,
|
|
1421
|
-
hint: 'The imported EDA extension has no background-job helper, so the sync was awaited. Re-import dsh-eda-bridge-extension.zip to use the async + polling flow.',
|
|
1985
|
+
hint: 'The imported EDA extension has no background-job helper, so the sync was awaited. Re-import dsh-eda-bridge-extension-' + VERSION + '.zip to use the async + polling flow.',
|
|
1422
1986
|
}
|
|
1423
1987
|
}
|
|
1424
1988
|
return {
|
|
@@ -1720,7 +2284,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1720
2284
|
// Older EDA extension without the background-job helper: fall back to the
|
|
1721
2285
|
// blocking call (which may hit the tool timeout for large boards).
|
|
1722
2286
|
const blocking = asRecord(await callExtension('pcb_Document.autoRouting', [props], toolTimeout(args, timeoutMs)))
|
|
1723
|
-
return { ...blocking, backgroundUnavailable: true, props, hint: 'The imported EDA extension has no background-job helper, so routing was awaited. Re-import dsh-eda-bridge-extension.zip to get the async + polling flow.' }
|
|
2287
|
+
return { ...blocking, backgroundUnavailable: true, props, hint: 'The imported EDA extension has no background-job helper, so routing was awaited. Re-import dsh-eda-bridge-extension-' + VERSION + '.zip to get the async + polling flow.' }
|
|
1724
2288
|
}
|
|
1725
2289
|
const running = started.started === true || started.alreadyRunning === true
|
|
1726
2290
|
return {
|
|
@@ -1807,7 +2371,7 @@ const server = createServer((req, res) => {
|
|
|
1807
2371
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
1808
2372
|
if (req.method === 'GET' && url.pathname === '/healthz') {
|
|
1809
2373
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1810
|
-
res.end(JSON.stringify({ status: 'ok', version: VERSION, clients:
|
|
2374
|
+
res.end(JSON.stringify({ status: 'ok', version: VERSION, clients: pickClients().length }))
|
|
1811
2375
|
return
|
|
1812
2376
|
}
|
|
1813
2377
|
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|