dsh-vscode-mode 0.1.52 → 0.1.53

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/src/rpc.ts CHANGED
@@ -32,6 +32,10 @@ import { rulesList, rulesRead, rulesRemove, rulesSave, rulesToggle } from './rul
32
32
  import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
33
33
  import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
34
34
  import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from './fileOpenSettings.js'
35
+ import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
36
+ import { shellMenuRegister, shellMenuRemove, shellMenuStatus } from './integrate.js'
37
+ import { unityAdd, unityInstall, unityList, unityRemove } from './unityBridge.js'
38
+ import { handoffOpen, pendingState, pollPending } from './externalHandoff.js'
35
39
  import { buildReport } from './compat.js'
36
40
  import { findProfileDir, readDevForm, setDevForm } from './devForm.js'
37
41
  import { normalizeRel } from './tree.js'
@@ -216,6 +220,15 @@ async function latestPatchBackup(patchPath: string): Promise<string | undefined>
216
220
  return names.filter((n) => n.startsWith(base + '.bak-')).sort().reverse()[0]
217
221
  }
218
222
 
223
+ /** 读取设置中的深链基址(缺省/非法回退默认 3080)。 */
224
+ async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
225
+ const settings = ctx.get('settings')
226
+ const descriptor = settings?.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === FILE_OPEN_SETTINGS_NS)
227
+ const value = descriptor?.value as { integrationBaseUrl?: unknown } | undefined
228
+ const raw = typeof value?.integrationBaseUrl === 'string' ? value.integrationBaseUrl.trim() : ''
229
+ return raw || INTEGRATION_BASE_DEFAULT
230
+ }
231
+
219
232
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
220
233
  export function buildHandlers(
221
234
  ctx: Ctx,
@@ -524,16 +537,18 @@ export function buildHandlers(
524
537
  const settings = ctx.get('settings')
525
538
  const descriptor = settings?.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === FILE_OPEN_SETTINGS_NS)
526
539
  const value = descriptor?.value as { fileOpenTool?: unknown } | undefined
527
- return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool ?? FILE_OPEN_DEFAULT), revision: descriptor?.revision }
540
+ return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool ?? FILE_OPEN_DEFAULT), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision: descriptor?.revision }
528
541
  },
529
542
  'vscode.fileOpenSettingsUpdate': async (args) => {
530
543
  const settings = ctx.get('settings')
531
544
  if (!settings?.update) return { ok: false, error: '设置服务不可用' }
532
545
  try {
533
- await settings.update(FILE_OPEN_SETTINGS_NS, { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) }, args.expectedRevision)
546
+ const patch: Record<string, unknown> = { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) }
547
+ if (typeof args.integrationBaseUrl === 'string' && args.integrationBaseUrl.trim()) patch.integrationBaseUrl = args.integrationBaseUrl.trim()
548
+ await settings.update(FILE_OPEN_SETTINGS_NS, patch, args.expectedRevision)
534
549
  const descriptor = settings.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === FILE_OPEN_SETTINGS_NS)
535
550
  const value = descriptor?.value as { fileOpenTool?: unknown } | undefined
536
- return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool), revision: descriptor?.revision }
551
+ return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision: descriptor?.revision }
537
552
  } catch (error) { return { ok: false, error: String(error) }
538
553
  }
539
554
  },
@@ -548,6 +563,78 @@ export function buildHandlers(
548
563
  return { ok: false, error: String(error) }
549
564
  }
550
565
  },
566
+ 'edrv.integration.status': async () => {
567
+ try {
568
+ return { ok: true, ...(await shellMenuStatus(ctx, await integrationBaseUrlOf(ctx))) }
569
+ } catch (error) {
570
+ return { ok: false, error: String(error) }
571
+ }
572
+ },
573
+ 'edrv.integration.register': async () => {
574
+ try {
575
+ return { ok: true, ...(await shellMenuRegister(ctx, await integrationBaseUrlOf(ctx))) }
576
+ } catch (error) {
577
+ return { ok: false, error: String(error) }
578
+ }
579
+ },
580
+ 'edrv.integration.unregister': async () => {
581
+ try {
582
+ return { ok: true, ...(await shellMenuRemove(ctx, await integrationBaseUrlOf(ctx))) }
583
+ } catch (error) {
584
+ return { ok: false, error: String(error) }
585
+ }
586
+ },
587
+ 'edrv.unity.list': async () => {
588
+ try {
589
+ return { ok: true, ...(await unityList()) }
590
+ } catch (error) {
591
+ return { ok: false, error: String(error) }
592
+ }
593
+ },
594
+ 'edrv.unity.add': async (args) => {
595
+ try {
596
+ return { ok: true, project: await unityAdd(args.path) }
597
+ } catch (error) {
598
+ return { ok: false, error: String(error) }
599
+ }
600
+ },
601
+ 'edrv.unity.remove': async (args) => {
602
+ try {
603
+ await unityRemove(args.path)
604
+ return { ok: true }
605
+ } catch (error) {
606
+ return { ok: false, error: String(error) }
607
+ }
608
+ },
609
+ 'edrv.unity.install': async (args) => {
610
+ try {
611
+ return { ok: true, project: await unityInstall(args.path) }
612
+ } catch (error) {
613
+ return { ok: false, error: String(error) }
614
+ }
615
+ },
616
+ 'edrv.externalStat': async (args) => {
617
+ // 深链判型(无会话依赖):绝对路径直过 resolve;缺失/其他类型归入 missing
618
+ const fs = ctx.get('fs')
619
+ if (!fs) return { ok: false, error: '缺少 fs' }
620
+ try {
621
+ const target = await fs.resolve(args.path)
622
+ const info = await fs.stat(target)
623
+ if (!info || info.type === 'other') return { ok: true, kind: 'missing' }
624
+ return { ok: true, kind: info.type === 'directory' ? 'directory' : 'file' }
625
+ } catch (error) {
626
+ return { ok: false, error: String(error) }
627
+ }
628
+ },
629
+ 'edrv.external.handoff': async (args) => ({
630
+ ok: true,
631
+ ...handoffOpen({ paths: args.paths, line: args.line, column: args.column }),
632
+ }),
633
+ 'edrv.external.pending': async () => ({ ok: true, open: pollPending() }),
634
+ 'edrv.external.pendingState': async (args) => ({
635
+ ok: true,
636
+ delivered: pendingState(args.token, args.take === true).delivered,
637
+ }),
551
638
  'edrv.perf.inventory': async () => {
552
639
  try {
553
640
  const home = dshHome()
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-vscode-mode shared — 外部深链 URL 契约(Windows launcher / Unity 包 / client 共用)。
3
+ * 形态:?<FLAG>=1&<PATHS_KEY>=<enc1>[,<enc2>…] [&<LINE_KEY>=N] [&<COLUMN_KEY>=M]
4
+ * 每个路径独立 percent-encode(encodeURIComponent / Uri.EscapeDataString)后以 `,` 连接:
5
+ * 编码结果不含裸逗号(Windows 路径本身不允许逗号,编码兜底其余字符),URLSearchParams
6
+ * 取值做一次标准解码即得 `enc1,enc2`,按 `,` split 即路径,无需二次 decode。
7
+ * Purity rule: no node/react imports(URLSearchParams 为标准全局)。
8
+ * 作者 ddj 2026-09-07
9
+ */
10
+
11
+ /** 深链开关参数名(值恒为 '1')。 */
12
+ export const EDRV_OPEN_FLAG = 'edrvOpen'
13
+ /** 路径列表参数名(逗号分隔的已编码路径)。 */
14
+ export const EDRV_PATHS_KEY = 'edrvPaths'
15
+ /** 目标行参数名(1-based,可选,作用于首个路径)。 */
16
+ export const EDRV_LINE_KEY = 'edrvLine'
17
+ /** 目标列参数名(1-based,可选,作用于首个路径)。 */
18
+ export const EDRV_COLUMN_KEY = 'edrvColumn'
19
+ /** 深链参数键全集(URL 清理用)。 */
20
+ export const EDRV_PARAM_KEYS = [EDRV_OPEN_FLAG, EDRV_PATHS_KEY, EDRV_LINE_KEY, EDRV_COLUMN_KEY]
21
+ /** 路径分隔符(编码后路径不含裸逗号,分隔安全)。 */
22
+ export const PATHS_SEPARATOR = ','
23
+
24
+ /** 一次外部深链请求(解析结果)。 */
25
+ export interface OpenParams {
26
+ /** 待打开路径(已解码,首个路径可携带行列)。 */
27
+ paths: string[]
28
+ /** 目标行(1-based,可空)。 */
29
+ line?: number
30
+ /** 目标列(1-based,可空)。 */
31
+ column?: number
32
+ }
33
+
34
+ /**
35
+ * 解析深链查询串:非深链/路径为空 → null;行列仅接受正整数,非法忽略。
36
+ * @author ddj 2026年09月07号
37
+ * @param search location.search(含或不含前导 ? 均可)
38
+ * @returns 深链参数或 null
39
+ */
40
+ export function parseOpenParams(search: string): OpenParams | null {
41
+ const params = new URLSearchParams(search ?? '')
42
+ if (params.get(EDRV_OPEN_FLAG) !== '1') return null
43
+ const paths = (params.get(EDRV_PATHS_KEY) ?? '')
44
+ .split(PATHS_SEPARATOR)
45
+ .map((item) => item.trim())
46
+ .filter(Boolean)
47
+ if (!paths.length) return null
48
+ const line = positiveIntOf(params.get(EDRV_LINE_KEY))
49
+ const column = positiveIntOf(params.get(EDRV_COLUMN_KEY))
50
+ return { paths, line, column }
51
+ }
52
+
53
+ /**
54
+ * 正整数解析(非法/非正 → undefined)。
55
+ * @author ddj 2026年09月07号
56
+ * @param raw 参数原文(可空)
57
+ * @returns 正整数或 undefined
58
+ */
59
+ function positiveIntOf(raw: string | null): number | undefined {
60
+ const value = Number(raw)
61
+ return Number.isInteger(value) && value > 0 ? value : undefined
62
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * dsh-vscode-mode shared — 系统集成载荷契约(Windows 右键菜单 + Unity 一键安装/更新)。
3
+ * Purity rule: no node/react imports(platform 用 string 表示)。
4
+ * 作者 ddj 2026-09-07
5
+ */
6
+
7
+ /** Unity 内嵌包名(UPM 包名,同时是项目 Packages/ 下的目录名)。 */
8
+ export const UNITY_PACKAGE_NAME = 'com.dsh.editor'
9
+ /** 注册表菜单键叶子名(HKCU\Software\Classes\*\shell\<leaf>)。 */
10
+ export const SHELL_MENU_LEAF = 'DSHEditor'
11
+ /** 深链基址默认值(DSH Web GUI 默认端口,dsh-web.cmd 实证)。 */
12
+ export const INTEGRATION_BASE_DEFAULT = 'http://127.0.0.1:3080'
13
+
14
+ /** launcher 安装态。 */
15
+ export interface ShellLauncherState {
16
+ /** launcher 文件是否已安装(编译 exe 或降级 ps1)。 */
17
+ present: boolean
18
+ /** 是否为已编译 exe(false = 降级 ps1)。 */
19
+ compiled: boolean
20
+ /** launcher 文件绝对路径(未安装时为期望路径)。 */
21
+ path: string
22
+ }
23
+
24
+ /** 一条右键菜单注册条目(跨平台:Windows 三键 / Linux 两项 / macOS 快速操作)。 */
25
+ export interface ShellMenuEntry {
26
+ /** 条目 id(files/dir/dirBackground/nautilus/dolphin/finder)。 */
27
+ id: string
28
+ /** 展示名。 */
29
+ label: string
30
+ /** 是否已注册。 */
31
+ registered: boolean
32
+ /** 是否需手动创建(macOS Automator 配方)。 */
33
+ manual?: boolean
34
+ }
35
+
36
+ /** 系统集成状态(edrv.integration.status/register/unregister 载荷)。 */
37
+ export interface ShellIntegrationStatus {
38
+ /** 宿主平台('win32'/'linux'/'darwin')。 */
39
+ platform: string
40
+ launcher: ShellLauncherState
41
+ /** 菜单注册条目(按平台)。 */
42
+ entries: ShellMenuEntry[]
43
+ /** 深链基址(设置 integrationBaseUrl)。 */
44
+ baseUrl: string
45
+ /** Unity 包源目录(随插件分发的 unity/com.dsh.editor 绝对路径)。 */
46
+ unityPackagePath: string
47
+ }
48
+
49
+ /** 一个已登记 Unity 项目的安装状态(edrv.unity.* 载荷)。 */
50
+ export interface UnityProjectEntry {
51
+ /** 项目根(归一化绝对路径)。 */
52
+ path: string
53
+ /** 目录名(展示标题)。 */
54
+ title: string
55
+ /** 已安装版本(未安装 → null)。 */
56
+ installedVersion: string | null
57
+ /** 已安装且版本等于包源版本。 */
58
+ upToDate: boolean
59
+ /** 项目目录不存在。 */
60
+ missingDir: boolean
61
+ /** 非法原因(非 Unity 项目根等)。 */
62
+ error?: string
63
+ }
64
+
65
+ /** edrv.unity.list 载荷。 */
66
+ export interface UnityListPayload {
67
+ /** 包源版本(unity/com.dsh.editor/package.json 的 version)。 */
68
+ sourceVersion: string | null
69
+ /** 包源目录绝对路径。 */
70
+ sourcePath: string
71
+ /** 已登记项目及安装状态。 */
72
+ projects: UnityProjectEntry[]
73
+ }
package/src/shared/rpc.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  } from './types.js'
15
15
  import type { MpcConfig, MpcProject, MpcProjectSaveInput, MpcServer } from './mcp.js'
16
16
  import type { CompatReport, DevFormInfo } from './compat.js'
17
+ import type { ShellIntegrationStatus, UnityListPayload, UnityProjectEntry } from './integration.js'
17
18
  import type { RuleInfo, RuleProject, RuleRefInput, RuleSaveInput } from './rules.js'
18
19
  import type { LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocation, LspMarketItem, LspPosition, LspSemanticTokens, LspServerStatus, LspSymbol } from './lsp.js'
19
20
 
@@ -132,7 +133,18 @@ export interface RpcRequestMap {
132
133
  'mcp.projectToggle': { workspacePath: string; serverName: string; enabled: boolean }
133
134
  'mcp.projectRefresh': { workspacePath: string; serverName: string }
134
135
  'vscode.fileOpenSettingsGet': {}
135
- 'vscode.fileOpenSettingsUpdate': { fileOpenTool: string; expectedRevision?: number }
136
+ 'vscode.fileOpenSettingsUpdate': { fileOpenTool: string; integrationBaseUrl?: string; expectedRevision?: number }
137
+ 'edrv.integration.status': {}
138
+ 'edrv.integration.register': {}
139
+ 'edrv.integration.unregister': {}
140
+ 'edrv.unity.list': {}
141
+ 'edrv.unity.add': { path: string }
142
+ 'edrv.unity.remove': { path: string }
143
+ 'edrv.unity.install': { path: string }
144
+ 'edrv.externalStat': { path: string }
145
+ 'edrv.external.handoff': { paths: string[]; line?: number; column?: number }
146
+ 'edrv.external.pending': {}
147
+ 'edrv.external.pendingState': { token: string; take?: boolean }
136
148
  'vscode.devFormGet': {}
137
149
  'vscode.devFormSet': { enabled: boolean; path?: string }
138
150
  'compat': {}
@@ -203,8 +215,19 @@ export interface RpcOkMap {
203
215
  'mcp.projectRemove': { project: MpcProject }
204
216
  'mcp.projectToggle': { project: MpcProject }
205
217
  'mcp.projectRefresh': { project: MpcProject }
206
- 'vscode.fileOpenSettingsGet': { fileOpenTool: string; revision?: number }
207
- 'vscode.fileOpenSettingsUpdate': { fileOpenTool: string; revision?: number }
218
+ 'vscode.fileOpenSettingsGet': { fileOpenTool: string; integrationBaseUrl: string; revision?: number }
219
+ 'vscode.fileOpenSettingsUpdate': { fileOpenTool: string; integrationBaseUrl: string; revision?: number }
220
+ 'edrv.integration.status': ShellIntegrationStatus
221
+ 'edrv.integration.register': ShellIntegrationStatus
222
+ 'edrv.integration.unregister': ShellIntegrationStatus
223
+ 'edrv.unity.list': UnityListPayload
224
+ 'edrv.unity.add': { project: UnityProjectEntry }
225
+ 'edrv.unity.remove': object
226
+ 'edrv.unity.install': { project: UnityProjectEntry }
227
+ 'edrv.externalStat': { kind: 'file' | 'directory' | 'missing' }
228
+ 'edrv.external.handoff': { clients: number; token: string }
229
+ 'edrv.external.pending': { open: { paths: string[]; line?: number; column?: number } | null }
230
+ 'edrv.external.pendingState': { delivered: boolean }
208
231
  'vscode.devFormGet': { devForm: DevFormInfo }
209
232
  'vscode.devFormSet': { devForm: DevFormInfo; restart: boolean }
210
233
  'compat': { report: CompatReport }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * dsh-vscode-mode host — Unity 内嵌包一键安装/更新。
3
+ * 机制:把包源目录 unity/com.dsh.editor 复制为 <Unity项目根>/Packages/com.dsh.editor
4
+ * (Unity 内嵌包自动发现,无需改 manifest.json);安装/更新同入口(整目录替换);
5
+ * 卸载 = 删除目标目录。登记清单存 ~/.dsh/dsh-vscode-mode/unity-projects.json。
6
+ * 安全:只触碰固定目标目录;项目根须含 Assets + ProjectSettings;目标已存在且
7
+ * package.json name 不是本包 → 拒绝覆盖。纯路径构造与项目特征判定可单测。
8
+ * 作者 ddj 2026-09-07
9
+ */
10
+ import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
11
+ import { basename, dirname, join, sep } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { dshHome, PLUGIN_ID } from './paths.js'
14
+ import { UNITY_PACKAGE_NAME } from './shared/integration.js'
15
+ import type { UnityListPayload, UnityProjectEntry } from './shared/integration.js'
16
+
17
+ /** Unity 项目特征目录。 */
18
+ const UNITY_MARKERS = ['Assets', 'ProjectSettings']
19
+ /** 登记清单文件名。 */
20
+ const PROJECTS_FILE = 'unity-projects.json'
21
+ /** 包源目录相对插件包根的位置。 */
22
+ const UNITY_DIR_NAME = 'unity'
23
+
24
+ /**
25
+ * 包源目录(随插件包分发的 unity/com.dsh.editor 绝对路径)。
26
+ * @author ddj 2026年09月07号
27
+ * @param moduleUrl 模块 URL(缺省 import.meta.url;测试注入)
28
+ * @returns 包源目录绝对路径
29
+ */
30
+ export function unitySourceDir(moduleUrl: string = import.meta.url): string {
31
+ return join(dirname(fileURLToPath(moduleUrl)), '..', UNITY_DIR_NAME, UNITY_PACKAGE_NAME)
32
+ }
33
+
34
+ /**
35
+ * 项目登记清单文件(~/.dsh/dsh-vscode-mode/unity-projects.json)。
36
+ * @author ddj 2026年09月07号
37
+ * @param home DSH home(测试可注入)
38
+ * @returns 清单文件绝对路径
39
+ */
40
+ export function unityProjectsFile(home = dshHome()): string {
41
+ return join(home, PLUGIN_ID, PROJECTS_FILE)
42
+ }
43
+
44
+ /**
45
+ * 归一化用户输入的项目根:去首尾空白/引号/尾部分隔符。
46
+ * @author ddj 2026年09月07号
47
+ * @param raw 原始输入
48
+ * @returns 归一化绝对路径
49
+ */
50
+ export function normalizeUnityRoot(raw: string): string {
51
+ return String(raw ?? '').trim().replace(/^["']|["']$/g, '').replace(/[\\/]+$/, '')
52
+ }
53
+
54
+ /**
55
+ * 目录名清单是否满足 Unity 项目特征(Assets + ProjectSettings 同时存在)。
56
+ * @author ddj 2026年09月07号
57
+ * @param entries 项目根下的目录名清单
58
+ * @returns 是否 Unity 项目根
59
+ */
60
+ export function isUnityProject(entries: string[]): boolean {
61
+ return UNITY_MARKERS.every((marker) => entries.includes(marker))
62
+ }
63
+
64
+ /**
65
+ * 内嵌包目标目录(固定:<root>/Packages/com.dsh.editor)。
66
+ * @author ddj 2026年09月07号
67
+ * @param root Unity 项目根
68
+ * @returns 目标目录绝对路径
69
+ */
70
+ export function unityTargetOf(root: string): string {
71
+ return join(root, 'Packages', UNITY_PACKAGE_NAME)
72
+ }
73
+
74
+ /**
75
+ * 读 package.json 的 version(缺失/非法 → null)。
76
+ * @author ddj 2026年09月07号
77
+ * @param pkgFile package.json 绝对路径
78
+ * @returns 版本号或 null
79
+ */
80
+ export async function readPackageVersion(pkgFile: string): Promise<string | null> {
81
+ try {
82
+ const data = JSON.parse(await readFile(pkgFile, 'utf8')) as { version?: unknown }
83
+ return typeof data?.version === 'string' && data.version ? data.version : null
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ /** 登记清单单条记录。 */
90
+ interface ProjectRecord { path: string; addedAt: string }
91
+
92
+ /** 读登记清单(缺失/非法 → 空数组)。 */
93
+ async function readProjects(home = dshHome()): Promise<ProjectRecord[]> {
94
+ try {
95
+ const data: unknown = JSON.parse(await readFile(unityProjectsFile(home), 'utf8'))
96
+ if (!Array.isArray(data)) return []
97
+ return (data as unknown[])
98
+ .filter((item): item is ProjectRecord => !!item && typeof (item as ProjectRecord).path === 'string')
99
+ .map((item) => ({ path: String(item.path), addedAt: String(item.addedAt ?? '') }))
100
+ } catch {
101
+ return []
102
+ }
103
+ }
104
+
105
+ /** 写登记清单(父目录按需创建)。 */
106
+ async function writeProjects(records: ProjectRecord[], home = dshHome()): Promise<void> {
107
+ const file = unityProjectsFile(home)
108
+ await mkdir(dirname(file), { recursive: true })
109
+ await writeFile(file, JSON.stringify(records, null, 2) + '\n', 'utf8')
110
+ }
111
+
112
+ /**
113
+ * 单个项目的安装状态视图(版本对比 + 合法性)。
114
+ * @author ddj 2026年09月07号
115
+ * @param rawPath 项目路径(原始输入)
116
+ * @param moduleUrl 模块 URL(测试注入)
117
+ * @returns 项目状态
118
+ */
119
+ export async function unityEntryOf(rawPath: string, moduleUrl: string = import.meta.url): Promise<UnityProjectEntry> {
120
+ const root = normalizeUnityRoot(rawPath)
121
+ const entry: UnityProjectEntry = { path: root, title: basename(root) || root, installedVersion: null, upToDate: false, missingDir: false }
122
+ let names: string[] = []
123
+ try {
124
+ names = await readdir(root)
125
+ } catch {
126
+ entry.missingDir = true
127
+ return entry
128
+ }
129
+ if (!isUnityProject(names)) {
130
+ entry.error = '不是 Unity 项目根(缺少 Assets 或 ProjectSettings 目录)'
131
+ return entry
132
+ }
133
+ const installed = await readPackageVersion(join(unityTargetOf(root), 'package.json'))
134
+ entry.installedVersion = installed
135
+ const sourceVersion = await readPackageVersion(join(unitySourceDir(moduleUrl), 'package.json'))
136
+ entry.upToDate = installed !== null && installed === sourceVersion
137
+ return entry
138
+ }
139
+
140
+ /**
141
+ * 列出登记项目与包源版本。
142
+ * @author ddj 2026年09月07号
143
+ * @param moduleUrl 模块 URL(测试注入)
144
+ * @param home DSH home(测试注入)
145
+ * @returns 列表载荷
146
+ */
147
+ export async function unityList(moduleUrl: string = import.meta.url, home = dshHome()): Promise<UnityListPayload> {
148
+ const source = unitySourceDir(moduleUrl)
149
+ const records = await readProjects(home)
150
+ const projects: UnityProjectEntry[] = []
151
+ for (const record of records) projects.push(await unityEntryOf(record.path, moduleUrl))
152
+ return { sourceVersion: await readPackageVersion(join(source, 'package.json')), sourcePath: source, projects }
153
+ }
154
+
155
+ /**
156
+ * 登记一个 Unity 项目(校验通过才入清单,重复登记幂等)。
157
+ * @author ddj 2026年09月07号
158
+ * @param rawPath 项目路径(原始输入)
159
+ * @param moduleUrl 模块 URL(测试注入)
160
+ * @param home DSH home(测试注入)
161
+ * @returns 项目状态
162
+ */
163
+ export async function unityAdd(rawPath: string, moduleUrl: string = import.meta.url, home = dshHome()): Promise<UnityProjectEntry> {
164
+ const root = normalizeUnityRoot(rawPath)
165
+ const entry = await unityEntryOf(root, moduleUrl)
166
+ if (entry.missingDir) throw new Error('目录不存在:' + root)
167
+ if (entry.error) throw new Error(entry.error)
168
+ const records = await readProjects(home)
169
+ if (!records.some((record) => normalizeUnityRoot(record.path) === root)) {
170
+ records.push({ path: root, addedAt: new Date().toISOString() })
171
+ await writeProjects(records, home)
172
+ }
173
+ return entry
174
+ }
175
+
176
+ /**
177
+ * 移除一个项目的登记(不删除已安装的包目录)。
178
+ * @author ddj 2026年09月07号
179
+ * @param rawPath 项目路径(原始输入)
180
+ * @param home DSH home(测试注入)
181
+ */
182
+ export async function unityRemove(rawPath: string, home = dshHome()): Promise<void> {
183
+ const root = normalizeUnityRoot(rawPath)
184
+ const records = await readProjects(home)
185
+ await writeProjects(records.filter((record) => normalizeUnityRoot(record.path) !== root), home)
186
+ }
187
+
188
+ /**
189
+ * 一键安装/更新:整目录替换 <root>/Packages/com.dsh.editor 为包源内容。
190
+ * @author ddj 2026年09月07号
191
+ * @param rawPath 项目路径(原始输入)
192
+ * @param moduleUrl 模块 URL(测试注入)
193
+ * @returns 安装后项目状态
194
+ */
195
+ export async function unityInstall(rawPath: string, moduleUrl: string = import.meta.url): Promise<UnityProjectEntry> {
196
+ const root = normalizeUnityRoot(rawPath)
197
+ const source = unitySourceDir(moduleUrl)
198
+ const target = unityTargetOf(root)
199
+ await validateInstallTarget(root, source, target)
200
+ await rm(target, { recursive: true, force: true })
201
+ await mkdir(join(target, '..'), { recursive: true })
202
+ await cp(source, target, { recursive: true })
203
+ return unityEntryOf(root, moduleUrl)
204
+ }
205
+
206
+ /**
207
+ * 安装前校验:Unity 项目根 + 目标不与包源重叠 + 已存在目标必须是本包。
208
+ * @author ddj 2026年09月07号
209
+ * @param root 项目根
210
+ * @param source 包源目录
211
+ * @param target 目标目录
212
+ */
213
+ async function validateInstallTarget(root: string, source: string, target: string): Promise<void> {
214
+ let names: string[] = []
215
+ try {
216
+ names = await readdir(root)
217
+ } catch {
218
+ throw new Error('目录不存在:' + root)
219
+ }
220
+ if (!isUnityProject(names)) throw new Error('不是 Unity 项目根(缺少 Assets 或 ProjectSettings 目录)')
221
+ if (target === source || target.startsWith(source + sep) || source.startsWith(target + sep)) {
222
+ throw new Error('目标目录与包源重叠,拒绝安装')
223
+ }
224
+ const text = await readFile(join(target, 'package.json'), 'utf8').catch((error: NodeJS.ErrnoException) => {
225
+ if (error?.code === 'ENOENT') return null
226
+ throw error
227
+ })
228
+ if (text === null) return
229
+ let name: unknown = null
230
+ try {
231
+ name = (JSON.parse(text) as { name?: unknown }).name
232
+ } catch { /* 解析失败按未知包处理 */ }
233
+ if (name !== UNITY_PACKAGE_NAME) throw new Error('目标目录已存在且不是 DSH 包,拒绝覆盖:' + target)
234
+ }