@77-zhike/app-sdk 0.2.0-d7b1.13 → 0.2.0-d7b1.15

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/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # 智客 App SDK 与 CLI
2
2
 
3
+ ## 操作系统与 Windows 候选
4
+
5
+ 当前 npm 发布版的 Developer CLI 仅提供 macOS Keychain 适配。本开发分支新增 Windows Credential Manager 候选:复用现有浏览器 PKCE 和 Session,使用系统 Windows PowerShell 5.1 固定桥接,refresh credential 按当前用户和 API origin 存储。
6
+
7
+ Windows 候选通过独立的 `SDK Windows 自动化验证(不发布)` Actions 检查,分别运行 Node 22/24。测试制品仅供验证,未发布 npm;Windows 11 桌面、系统默认浏览器弹起、双用户和注销重登暂未覆盖。完整结果与边界见 `openspec/changes/windows-app-developer-cli/`。
8
+
9
+ `zhike doctor --json` 的 `secureStore` 检查表示当前机制可用性,`writeAccess: not_checked` 和 `authentication: not_checked` 不证明凭据已保存、登录有效或业务可用。系统策略禁止桥接时会返回安全存储错误,不修改系统策略或降级为明文文件。
10
+
3
11
  ## 获准依赖
4
12
 
5
13
  工具链从 `src/toolchain/allowed-packages.json` 读取平台维护的包白名单。目前允许 `@77-zhike/qiqi-openapi@0.1.0` 的服务端包根入口 `.`;前端不可导入。项目安装依赖后直接使用静态 ESM import,真实解析的包名、精确版本、入口及间接模块在打包时核对,代码合并进 Artifact。App 的 Manifest、Settings 或 `package.json` 不能扩展名单。
@@ -220,7 +228,7 @@ Desktop 与 H5 均支持 17 个可视 Forms 节点,以及只读 `WithState`。
220
228
 
221
229
  ## Dialog
222
230
 
223
- 通过 `showDialog({ title, Dialog })` 打开临时界面;内容组件接收 `hideDialog()`。Desktop 复用 Dialog,H5 复用 Sheet,继续组合已有 UI/Form。当前不提供 DialogList、嵌套 Dialog 或固定 Footer,按钮直接放在正文中。
231
+ 通过 `showDialog({ title, Dialog, size })` 打开临时界面;内容组件接收 `hideDialog()`。Desktop 复用 Dialog,H5 复用 Sheet,继续组合已有 UI/Form。可选 `size` 为 `default | wide | content`:默认保持原规格,宽版最大 1120px,自适应按内容固有宽度展开并受视口约束;H5 均保持全屏 Sheet。当前不提供 DialogList、嵌套 Dialog 或固定 Footer,按钮直接放在正文中。
224
232
 
225
233
  从 Page/Settings 事件或 Record Action 调用。普通关闭完成 Promise;身份失效或打开失败拒绝。Action 正常返回后已受理 Dialog 可继续使用;保存必须由 App 显式调用 Server Function。Dialog 内不提供 Page 导航身份。详见开发者文档 `app-sdk/dialogs/show-dialog`。
226
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@77-zhike/app-sdk",
3
- "version": "0.2.0-d7b1.13",
3
+ "version": "0.2.0-d7b1.15",
4
4
  "description": "智客 Integration App 一期 SDK 与本地 Toolchain",
5
5
  "repository": {
6
6
  "type": "git",
@@ -67,5 +67,5 @@
67
67
  "optional": true
68
68
  }
69
69
  },
70
- "gitHead": "f2a667ec95a8d1787cf0c0655787266317069b55"
70
+ "gitHead": "cd6cf8462837c082eca5c5ccf2fb1dab8441885c"
71
71
  }
@@ -2,6 +2,7 @@ import { execFile as execFileCallback } from "node:child_process";
2
2
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import { createServer } from "node:http";
4
4
  import { promisify } from "node:util";
5
+ import { win32 } from "node:path";
5
6
 
6
7
  import { CLI_CLIENT_ID } from "../config.js";
7
8
  import { ZhikeCliError } from "../errors.js";
@@ -17,25 +18,33 @@ export async function authorizeCli({
17
18
  openBrowser = openSystemBrowser,
18
19
  onProgress = () => {},
19
20
  timeoutMs = 120_000,
21
+ signal,
20
22
  }) {
21
23
  const state = randomBytes(32).toString("base64url");
22
24
  const codeVerifier = randomBytes(48).toString("base64url");
23
25
  const codeChallenge = createHash("sha256")
24
26
  .update(codeVerifier, "ascii")
25
27
  .digest("base64url");
26
- const callback = await createLoopbackCallback({ state, timeoutMs });
27
- const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
28
- const authorizationUrl = new URL("/cli/authorize", consoleBaseUrl);
29
- authorizationUrl.searchParams.set("client_id", CLI_CLIENT_ID);
30
- authorizationUrl.searchParams.set("redirect_uri", redirectUri);
31
- authorizationUrl.searchParams.set("code_challenge", codeChallenge);
32
- authorizationUrl.searchParams.set("code_challenge_method", "S256");
33
- authorizationUrl.searchParams.set("state", state);
34
-
28
+ const cancellation = new AbortController();
29
+ const stop = () => cancellation.abort();
30
+ const authorizationSignal = signal || cancellation.signal;
31
+ if (!signal) { process.once("SIGINT", stop); process.once("SIGTERM", stop); }
32
+ let callback;
35
33
  try {
34
+ if (authorizationSignal.aborted) throw new ZhikeCliError("zhike_cli_authorization_cancelled", "Developer CLI 授权已取消", { exitCode: 130 });
35
+ callback = await createLoopbackCallback({ state, timeoutMs, signal: authorizationSignal });
36
+ if (authorizationSignal.aborted) await callback.waitForCode;
37
+ const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
38
+ const authorizationUrl = new URL("/cli/authorize", consoleBaseUrl);
39
+ authorizationUrl.searchParams.set("client_id", CLI_CLIENT_ID);
40
+ authorizationUrl.searchParams.set("redirect_uri", redirectUri);
41
+ authorizationUrl.searchParams.set("code_challenge", codeChallenge);
42
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
43
+ authorizationUrl.searchParams.set("state", state);
36
44
  callback.waitForCode.catch(() => {});
37
45
  onProgress("正在浏览器中完成 Developer CLI 授权...");
38
- await openBrowser(authorizationUrl.toString());
46
+ try { await openBrowser(authorizationUrl.toString()); }
47
+ catch { onProgress(`无法自动打开浏览器,请手动访问:${authorizationUrl}`); }
39
48
  const code = await callback.waitForCode;
40
49
  onProgress("已收到浏览器授权,正在完成 Developer CLI 登录...");
41
50
  const api = new DeveloperApiClient({ apiBaseUrl, fetchImpl });
@@ -45,10 +54,11 @@ export async function authorizeCli({
45
54
  redirect_uri: redirectUri,
46
55
  code_verifier: codeVerifier,
47
56
  });
48
- await credentialStore.set(apiBaseUrl, token.refresh_credential);
57
+ await persistRefreshCredential(api, token.refresh_credential, credentialStore);
49
58
  return renewableClient(api, token.access_token, credentialStore);
50
59
  } finally {
51
- await callback.close();
60
+ await callback?.close();
61
+ if (!signal) { process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop); }
52
62
  }
53
63
  }
54
64
 
@@ -77,7 +87,7 @@ export async function authenticateCli({
77
87
  }
78
88
  if (token) {
79
89
  if (token.refresh_credential !== refreshCredential) {
80
- await credentialStore.set(apiBaseUrl, token.refresh_credential);
90
+ await persistRefreshCredential(api, token.refresh_credential, credentialStore);
81
91
  }
82
92
  return renewableClient(api, token.access_token, credentialStore);
83
93
  }
@@ -98,16 +108,27 @@ export async function authenticateCli({
98
108
  });
99
109
  }
100
110
 
101
- export async function openSystemBrowser(url, platform = process.platform) {
111
+ export async function openSystemBrowser(url, platform = process.platform, { runner = execFile, systemRoot = process.env.SystemRoot } = {}) {
112
+ const parsed = new URL(url);
113
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) {
114
+ throw new ZhikeCliError("zhike_browser_open_failed", "浏览器地址必须是无凭据的 HTTP(S) URL");
115
+ }
116
+ // URL 只作为 base64 数据传入固定表达式,避免 cmd/start 把 query 中的 & 当成命令。
117
+ const encodedUrl = Buffer.from(parsed.href, "utf8").toString("base64");
118
+ const windowsCommand = `Start-Process -FilePath ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedUrl}')))`;
102
119
  const command =
103
120
  platform === "darwin"
104
121
  ? ["open", [url]]
105
122
  : platform === "win32"
106
- ? ["cmd", ["/c", "start", "", url]]
123
+ ? [systemRoot && win32.isAbsolute(systemRoot)
124
+ ? win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe") : "",
125
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(windowsCommand, "utf16le").toString("base64")]]
107
126
  : ["xdg-open", [url]];
108
127
  try {
109
- await execFile(command[0], command[1], {
128
+ if (!command[0]) throw new Error("system browser unavailable");
129
+ await runner(command[0], command[1], {
110
130
  windowsHide: true,
131
+ timeout: 10_000,
111
132
  });
112
133
  } catch (error) {
113
134
  throw new ZhikeCliError(
@@ -127,14 +148,30 @@ function renewableClient(api, accessToken, credentialStore) {
127
148
  }
128
149
  const token = await api.refreshCliSession(refreshCredential);
129
150
  if (token.refresh_credential !== refreshCredential) {
130
- await credentialStore.set(api.apiBaseUrl, token.refresh_credential);
151
+ await persistRefreshCredential(api, token.refresh_credential, credentialStore);
131
152
  }
132
153
  return token.access_token;
133
154
  };
134
155
  return client;
135
156
  }
136
157
 
137
- async function createLoopbackCallback({ state, timeoutMs }) {
158
+ async function persistRefreshCredential(api, refreshCredential, credentialStore) {
159
+ try { await credentialStore.set(api.apiBaseUrl, refreshCredential); }
160
+ catch {
161
+ let cleanupConfirmed = false;
162
+ const previousSignal = api.signal;
163
+ try {
164
+ api.signal = AbortSignal.timeout(5_000);
165
+ await api.logoutCliSession(refreshCredential);
166
+ cleanupConfirmed = true;
167
+ } catch { /* 网络失败不改变本地存储失败这一事实。 */ }
168
+ finally { api.signal = previousSignal; }
169
+ throw new ZhikeCliError("zhike_secure_store_failed", "无法保存 Developer CLI 登录凭据,请修复系统安全存储后重新登录"
170
+ + (cleanupConfirmed ? "" : ";远端会话清理未确认"));
171
+ }
172
+ }
173
+
174
+ async function createLoopbackCallback({ state, timeoutMs, signal }) {
138
175
  let timer;
139
176
  let resolveCode;
140
177
  let rejectCode;
@@ -142,6 +179,7 @@ async function createLoopbackCallback({ state, timeoutMs }) {
142
179
  resolveCode = resolve;
143
180
  rejectCode = reject;
144
181
  });
182
+ waitForCode.catch(() => {});
145
183
  const server = createServer((request, response) => {
146
184
  const url = new URL(request.url || "/", "http://127.0.0.1");
147
185
  const receivedState = url.searchParams.get("state") || "";
@@ -169,6 +207,13 @@ async function createLoopbackCallback({ state, timeoutMs }) {
169
207
  server.once("error", reject);
170
208
  server.listen(0, "127.0.0.1", resolve);
171
209
  });
210
+ const cancel = () => {
211
+ rejectCode(new ZhikeCliError("zhike_cli_authorization_cancelled", "Developer CLI 授权已取消", { exitCode: 130 }));
212
+ server.close();
213
+ server.closeAllConnections();
214
+ };
215
+ signal?.addEventListener("abort", cancel, { once: true });
216
+ if (signal?.aborted) cancel();
172
217
  const address = server.address();
173
218
  if (!address || typeof address === "string") {
174
219
  server.close();
@@ -190,6 +235,7 @@ async function createLoopbackCallback({ state, timeoutMs }) {
190
235
  waitForCode,
191
236
  close: async () => {
192
237
  clearTimeout(timer);
238
+ signal?.removeEventListener("abort", cancel);
193
239
  if (!server.listening) {
194
240
  return;
195
241
  }
@@ -1,7 +1,9 @@
1
1
  import { execFile as execFileCallback } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { randomUUID } from "node:crypto";
3
4
 
4
5
  import { ZhikeCliError } from "../errors.js";
6
+ import { WindowsCredentialStore } from "./windows-credential-store.js";
5
7
 
6
8
  const execFile = promisify(execFileCallback);
7
9
  const KEYCHAIN_SERVICE = "com.77hub.zhike.developer-cli";
@@ -86,12 +88,31 @@ export function createSystemCredentialStore(platform = process.platform) {
86
88
  if (platform === "darwin") {
87
89
  return new MacOSKeychainCredentialStore();
88
90
  }
91
+ if (platform === "win32") {
92
+ return new WindowsCredentialStore();
93
+ }
89
94
  throw new ZhikeCliError(
90
95
  "zhike_secure_store_unavailable",
91
96
  "当前操作系统尚未配置安全凭据存储适配器",
92
97
  );
93
98
  }
94
99
 
100
+ export async function inspectSystemCredentialStore(platform = process.platform, createStore = createSystemCredentialStore) {
101
+ const provider = platform === "darwin" ? "macos-keychain" : platform === "win32" ? "windows-credential-manager" : null;
102
+ const check = { name: "secureStore", platform, provider, supported: Boolean(provider),
103
+ available: false, ok: false, writeAccess: "not_checked" };
104
+ if (!provider) return { ...check, code: "zhike_secure_store_unavailable" };
105
+ try {
106
+ const store = createStore(platform);
107
+ if (store.probe) await store.probe();
108
+ else await store.get(`https://probe-${randomUUID()}.invalid`);
109
+ return { ...check, available: true, ok: true };
110
+ } catch (error) {
111
+ return { ...check, code: ["zhike_secure_store_unavailable", "zhike_secure_store_failed"].includes(error?.code)
112
+ ? error.code : "zhike_secure_store_failed" };
113
+ }
114
+ }
115
+
95
116
  function _keychainError(error) {
96
117
  return new ZhikeCliError(
97
118
  "zhike_secure_store_failed",
@@ -5,6 +5,7 @@ import { resolve } from "node:path";
5
5
  import { loadCliConfig } from "../config.js";
6
6
  import { ZhikeCliError } from "../errors.js";
7
7
  import { readProjectBinding } from "../project-binding.js";
8
+ import { inspectSystemCredentialStore } from "./credential-store.js";
8
9
 
9
10
  const metadata = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
10
11
  export const commandHelp = {
@@ -72,7 +73,7 @@ export async function runLocalCommand(command, args, { cwd, environment }) {
72
73
  } catch {
73
74
  checks.push({ name: "package", ok: false, code: "package_missing_or_invalid" });
74
75
  }
75
- checks.push({ name: "secureStore", ok: process.platform === "darwin", platform: process.platform });
76
+ checks.push(await inspectSystemCredentialStore());
76
77
  const result = localEnvelope({ localReady: checks.every((check) => check.ok),
77
78
  authentication: "not_checked", installation: "not_checked", checks });
78
79
  result.target = config ? { environment: config.apiBaseUrl, appId: binding?.appId ?? null } : null;
@@ -0,0 +1,131 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ $ProgressPreference = 'SilentlyContinue'
3
+ $PSModuleAutoLoadingPreference = 'None'
4
+
5
+ try {
6
+ if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') {
7
+ [Console]::Out.Write('{"version":1,"ok":false,"code":"zhike_secure_store_unavailable"}')
8
+ exit 0
9
+ }
10
+ Import-Module "$PSHOME\Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1" -ErrorAction Stop
11
+ [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
12
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
13
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
14
+ $keys = @($request.PSObject.Properties.Name)
15
+ if ($request.version -ne 1 -or $request.operation -notin @('get', 'set', 'delete', 'probe') -or
16
+ @($keys | Where-Object { $_ -notin @('version', 'operation', 'origin', 'credential') }).Count -ne 0 -or
17
+ $request.origin -isnot [string] -or $request.origin.Length -gt 2048) { throw 'invalid request' }
18
+ $uri = New-Object System.Uri($request.origin, [System.UriKind]::Absolute)
19
+ if ($uri.Scheme -notin @('http', 'https') -or $uri.UserInfo -or $uri.AbsolutePath -ne '/' -or $uri.Query -or $uri.Fragment) { throw 'invalid origin' }
20
+ if ($request.operation -eq 'set') {
21
+ if ($request.credential -isnot [string] -or $request.credential.Length -lt 1 -or $request.credential.Length -gt 512) { throw 'invalid credential' }
22
+ } elseif ($keys -contains 'credential') { throw 'unexpected credential' }
23
+
24
+ # 仅绑定固定 Win32 API;全部在内存中构造,避免运行时编译器对 Unicode TEMP 的依赖。
25
+ $assembly = [AppDomain]::CurrentDomain.DefineDynamicAssembly(
26
+ (New-Object Reflection.AssemblyName('ZhikeCredentialBridge')), [Reflection.Emit.AssemblyBuilderAccess]::Run)
27
+ $module = $assembly.DefineDynamicModule('ZhikeCredentialBridge')
28
+ $layout = [Reflection.TypeAttributes]::Public -bor [Reflection.TypeAttributes]::SequentialLayout -bor [Reflection.TypeAttributes]::Sealed
29
+ $structure = $module.DefineType('ZhikeCredential', $layout, [ValueType])
30
+ # 字段顺序对应 CREDENTIALW;指针由当前进程位数决定,FILETIME 作为 64 位值对齐。
31
+ $fields = @(
32
+ @('Flags', [uint32]), @('Type', [uint32]), @('TargetName', [IntPtr]), @('Comment', [IntPtr]),
33
+ @('LastWritten', [long]), @('CredentialBlobSize', [uint32]), @('CredentialBlob', [IntPtr]),
34
+ @('Persist', [uint32]), @('AttributeCount', [uint32]), @('Attributes', [IntPtr]),
35
+ @('TargetAlias', [IntPtr]), @('UserName', [IntPtr]))
36
+ foreach ($field in $fields) { [void]$structure.DefineField($field[0], $field[1], [Reflection.FieldAttributes]::Public) }
37
+ $credentialType = $structure.CreateType()
38
+ $binding = $module.DefineType('ZhikeNativeCredential', [Reflection.TypeAttributes]::Public)
39
+ $dllImport = [Runtime.InteropServices.DllImportAttribute]
40
+ $importFields = [Reflection.FieldInfo[]]@($dllImport.GetField('EntryPoint'), $dllImport.GetField('CharSet'), $dllImport.GetField('SetLastError'))
41
+ $signatures = @(
42
+ @('Read', 'CredReadW', [bool], [type[]]@([string], [uint32], [uint32], [IntPtr].MakeByRefType())),
43
+ @('Write', 'CredWriteW', [bool], [type[]]@([IntPtr], [uint32])),
44
+ @('Delete', 'CredDeleteW', [bool], [type[]]@([string], [uint32], [uint32])),
45
+ @('Free', 'CredFree', [void], [type[]]@([IntPtr])))
46
+ foreach ($signature in $signatures) {
47
+ $method = $binding.DefineMethod($signature[0], [Reflection.MethodAttributes]'Public, Static, PinvokeImpl', $signature[2], $signature[3])
48
+ $attribute = New-Object Reflection.Emit.CustomAttributeBuilder(
49
+ $dllImport.GetConstructor([type[]]@([string])), [object[]]@('advapi32.dll'),
50
+ $importFields, [object[]]@($signature[1], [Runtime.InteropServices.CharSet]::Unicode, $true))
51
+ $method.SetCustomAttribute($attribute)
52
+ $method.SetImplementationFlags([Reflection.MethodImplAttributes]::PreserveSig)
53
+ }
54
+ $native = $binding.CreateType()
55
+ $hash = [Security.Cryptography.SHA256]::Create()
56
+ try { $digest = [BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($request.origin))).Replace('-', '').ToLowerInvariant() }
57
+ finally { $hash.Dispose() }
58
+ $target = 'com.77hub.zhike.developer-cli/v1/' + $digest
59
+ $value = $null
60
+ switch ($request.operation) {
61
+ { $_ -in @('get', 'probe') } {
62
+ $pointer = [IntPtr]::Zero
63
+ if (-not $native::Read($target, 1, 0, [ref]$pointer)) {
64
+ $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
65
+ if ($errorCode -ne 1168) { throw 'credential read failed' }
66
+ } else {
67
+ $blob = [IntPtr]::Zero
68
+ $blobSize = 0
69
+ try {
70
+ # 按固定结构的实际偏移读取,避免 PowerShell 在 PtrToStructure 的 Type/Object 重载间歧义。
71
+ $kind = [Runtime.InteropServices.Marshal]::ReadInt32($pointer, [Runtime.InteropServices.Marshal]::OffsetOf($credentialType, 'Type').ToInt32())
72
+ $persist = [Runtime.InteropServices.Marshal]::ReadInt32($pointer, [Runtime.InteropServices.Marshal]::OffsetOf($credentialType, 'Persist').ToInt32())
73
+ $blobSize = [Runtime.InteropServices.Marshal]::ReadInt32($pointer, [Runtime.InteropServices.Marshal]::OffsetOf($credentialType, 'CredentialBlobSize').ToInt32())
74
+ $blob = [Runtime.InteropServices.Marshal]::ReadIntPtr($pointer, [Runtime.InteropServices.Marshal]::OffsetOf($credentialType, 'CredentialBlob').ToInt32())
75
+ # Generic / 本用户本机跨登录持久化;BlobSize 单位为字节,内容为 UTF-16LE。
76
+ if ($kind -ne 1 -or $persist -ne 2 -or $blobSize -lt 1 -or $blobSize -gt 1024 -or
77
+ $blobSize % 2 -ne 0 -or $blob -eq [IntPtr]::Zero) { throw 'invalid credential metadata' }
78
+ if ($request.operation -eq 'get') {
79
+ $value = [Runtime.InteropServices.Marshal]::PtrToStringUni($blob, [int]($blobSize / 2))
80
+ }
81
+ } finally {
82
+ if ($blob -ne [IntPtr]::Zero -and $blobSize -gt 0 -and $blobSize -le 1024) {
83
+ for ($i = 0; $i -lt $blobSize; $i++) { [Runtime.InteropServices.Marshal]::WriteByte($blob, $i, 0) }
84
+ }
85
+ $native::Free($pointer)
86
+ }
87
+ }
88
+ }
89
+ 'set' {
90
+ $bytes = [Text.Encoding]::Unicode.GetBytes($request.credential)
91
+ $blob = [IntPtr]::Zero
92
+ $record = [IntPtr]::Zero
93
+ $namePointer = [IntPtr]::Zero
94
+ $userPointer = [IntPtr]::Zero
95
+ try {
96
+ $blob = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
97
+ [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob, $bytes.Length)
98
+ $namePointer = [Runtime.InteropServices.Marshal]::StringToHGlobalUni($target)
99
+ $userPointer = [Runtime.InteropServices.Marshal]::StringToHGlobalUni($request.origin)
100
+ $credential = [Activator]::CreateInstance($credentialType)
101
+ $credential.Type = 1
102
+ $credential.TargetName = $namePointer
103
+ $credential.UserName = $userPointer
104
+ $credential.CredentialBlob = $blob
105
+ $credential.CredentialBlobSize = $bytes.Length
106
+ $credential.Persist = 2
107
+ $record = [Runtime.InteropServices.Marshal]::AllocHGlobal([Runtime.InteropServices.Marshal]::SizeOf($credential))
108
+ [Runtime.InteropServices.Marshal]::StructureToPtr($credential, $record, $false)
109
+ if (-not $native::Write($record, 0)) { throw 'credential write failed' }
110
+ } finally {
111
+ [Array]::Clear($bytes, 0, $bytes.Length)
112
+ if ($blob -ne [IntPtr]::Zero) {
113
+ [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob, $bytes.Length)
114
+ [Runtime.InteropServices.Marshal]::FreeHGlobal($blob)
115
+ }
116
+ foreach ($allocated in @($record, $namePointer, $userPointer)) {
117
+ if ($allocated -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeHGlobal($allocated) }
118
+ }
119
+ }
120
+ }
121
+ 'delete' {
122
+ if (-not $native::Delete($target, 1, 0)) {
123
+ $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
124
+ if ($errorCode -ne 1168) { throw 'credential delete failed' }
125
+ }
126
+ }
127
+ }
128
+ [Console]::Out.Write((@{ version = 1; ok = $true; value = $value } | ConvertTo-Json -Compress))
129
+ } catch {
130
+ [Console]::Out.Write('{"version":1,"ok":false,"code":"zhike_secure_store_failed"}')
131
+ }
@@ -0,0 +1,112 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { win32 } from "node:path";
5
+
6
+ import { normalizeOrigin } from "../config.js";
7
+ import { ZhikeCliError } from "../errors.js";
8
+
9
+ let script;
10
+ const failure = () => new ZhikeCliError("zhike_secure_store_failed", "无法访问 Windows 安全凭据存储,请检查系统凭据库和 PowerShell 策略后重试");
11
+ const unavailable = () => new ZhikeCliError("zhike_secure_store_unavailable", "Windows 安全凭据适配不可用,需要系统 Windows PowerShell 5.1 及允许的原生 API 调用");
12
+
13
+ export class WindowsCredentialStore {
14
+ constructor({ runner = runWindowsCredentialBridge } = {}) {
15
+ this.runner = runner;
16
+ }
17
+
18
+ async get(environmentId) { return this.request("get", normalizeOrigin(environmentId, "Developer API")); }
19
+
20
+ async set(environmentId, refreshCredential) {
21
+ if (typeof refreshCredential !== "string" || !refreshCredential.length || refreshCredential.length > 512) throw failure();
22
+ await this.request("set", normalizeOrigin(environmentId, "Developer API"), refreshCredential);
23
+ }
24
+
25
+ async delete(environmentId) { await this.request("delete", normalizeOrigin(environmentId, "Developer API")); }
26
+
27
+ async probe() { await this.request("probe", `https://probe-${randomUUID()}.invalid`); }
28
+
29
+ async request(operation, origin, credential) {
30
+ try {
31
+ const result = await this.runner({ version: 1, operation, origin, ...(credential === undefined ? {} : { credential }) });
32
+ if (!result || result.version !== 1 || typeof result.ok !== "boolean" ||
33
+ Object.keys(result).some((key) => !["version", "ok", "value", "code"].includes(key))) throw failure();
34
+ if (!result.ok) {
35
+ if (result.code === "zhike_secure_store_unavailable") throw unavailable();
36
+ throw failure();
37
+ }
38
+ if (operation === "get") {
39
+ if (result.value !== null && (typeof result.value !== "string" || !result.value.length || result.value.length > 512)) throw failure();
40
+ return result.value;
41
+ }
42
+ if (result.value !== null) throw failure();
43
+ } catch (error) {
44
+ if (error instanceof ZhikeCliError) throw error;
45
+ throw failure();
46
+ }
47
+ }
48
+ }
49
+
50
+ /** 固定程序/脚本与秘密数据分离;子进程输出不得原文进入 CLI 错误。 */
51
+ export async function runWindowsCredentialBridge(request, {
52
+ spawnImpl = spawn, systemRoot = process.env.SystemRoot, environment = process.env,
53
+ timeoutMs = 10_000, maxOutputBytes = 65_536,
54
+ } = {}) {
55
+ if (!systemRoot || !win32.isAbsolute(systemRoot)) throw unavailable();
56
+ let source;
57
+ try { source = await (script ??= readFile(new URL("./windows-credential-bridge.ps1", import.meta.url), "utf8")); } catch { throw unavailable(); }
58
+ const executable = win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
59
+ const encoded = Buffer.from(source, "utf16le").toString("base64");
60
+ const env = Object.fromEntries(Object.entries(environment).filter(([key]) =>
61
+ ["systemroot", "windir", "temp", "tmp", "path", "comspec", "systemdrive",
62
+ "userprofile", "appdata", "localappdata", "homedrive", "homepath", "username", "userdomain",
63
+ "programfiles", "programfiles(x86)", "programw6432", "programdata", "allusersprofile",
64
+ "pathext", "computername", "processor_architecture", "processor_identifier", "processor_level",
65
+ "processor_revision", "number_of_processors", "os"].includes(key.toLowerCase())));
66
+ // ConvertFrom-Json / Add-Type 的模块解析只使用系统目录,不加载项目或用户自定义模块。
67
+ // 固定子进程不需要持久化命令发现缓存;避免依赖 runner/用户的缓存路径或锁。
68
+ env.PSModuleAnalysisCachePath = "NUL";
69
+ env.PSModulePath = win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "Modules");
70
+ return new Promise((resolve, reject) => {
71
+ let child;
72
+ let output = "";
73
+ let outputSize = 0;
74
+ let settled = false;
75
+ let timer;
76
+ const finish = (error, result) => {
77
+ if (settled) return;
78
+ settled = true;
79
+ clearTimeout(timer);
80
+ output = "";
81
+ if (error) reject(error); else resolve(result);
82
+ };
83
+ const terminate = () => {
84
+ if (Number.isInteger(child?.pid) && process.platform === "win32") {
85
+ execFile(win32.join(systemRoot, "System32", "taskkill.exe"), ["/PID", String(child.pid), "/T", "/F"],
86
+ { windowsHide: true, timeout: 3_000 }, () => child.kill());
87
+ } else child?.kill();
88
+ };
89
+ try {
90
+ child = spawnImpl(executable, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
91
+ { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env });
92
+ } catch { finish(unavailable()); return; }
93
+ timer = setTimeout(() => { terminate(); finish(failure()); }, timeoutMs);
94
+ const collect = (bytes, keep) => {
95
+ if (settled) return;
96
+ outputSize += Buffer.byteLength(bytes);
97
+ if (outputSize > maxOutputBytes) { terminate(); finish(failure()); return; }
98
+ if (keep) output += bytes;
99
+ };
100
+ child.stdout.setEncoding("utf8");
101
+ child.stdout.on("data", (bytes) => collect(bytes, true));
102
+ child.stderr.on("data", (bytes) => collect(bytes, false));
103
+ child.on("error", () => finish(unavailable()));
104
+ child.stdin.on("error", () => { terminate(); finish(failure()); });
105
+ child.on("close", (code) => {
106
+ if (settled) return;
107
+ if (code !== 0) { finish(failure()); return; }
108
+ try { finish(null, JSON.parse(output)); } catch { finish(failure()); }
109
+ });
110
+ child.stdin.end(JSON.stringify(request), "utf8");
111
+ });
112
+ }
@@ -154,6 +154,8 @@ export interface PageProps {
154
154
  export interface PageDefinition {
155
155
  /** Apps 菜单和 Page 标题使用的名称。 */
156
156
  name: string;
157
+ /** Apps 导航图标,必须来自 SDK 统一图标目录;省略时保留宿主默认图标。 */
158
+ icon?: AppIconName;
157
159
  /** 有值的 Page 优先按数值排序;缺失时按 slug 稳定排序。 */
158
160
  order?: number;
159
161
  /** Page 自身的产品布局语义,不选择 Dialog、Sheet 或 Sidebar。 */
@@ -219,6 +221,8 @@ export interface DialogProps {
219
221
  export interface DialogOptions {
220
222
  /** 宿主固定标题,去空白后 1~200 字符。 */
221
223
  title: string;
224
+ /** 默认保持原宽度;wide 为宽版,content 按内容固有宽度展开并受视口约束。Mobile 均为全屏 Sheet。 */
225
+ size?: 'default' | 'wide' | 'content';
222
226
  Dialog: ComponentType<DialogProps>;
223
227
  }
224
228
  /** 从有效 Action/UI 回调打开;普通关闭完成,失效/失败拒绝,不支持嵌套。 */
@@ -1,7 +1,9 @@
1
1
  import { frontendPoint } from './points.generated.js';
2
+ import { sdkIconNames } from '../ui/icons.generated.js';
2
3
  export { Destinations } from './destinations.js';
3
4
  const FRONTEND_EXTENSION_MARKER = Symbol.for("zhike.frontend-extension.v1");
4
5
  const PAGE_VARIANTS = new Set(["full", "centered"]);
6
+ const PAGE_ICONS = new Set(sdkIconNames);
5
7
 
6
8
  export function defineFrontendExtension(definition) {
7
9
  if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
@@ -26,7 +28,7 @@ export function definePage(definition) {
26
28
  throw new TypeError("Page definition 必须是对象");
27
29
  }
28
30
  const keys = Object.keys(definition).sort();
29
- const allowed = new Set(["Page", "name", "order", "variant", "showInNavigation", "showHeader"]);
31
+ const allowed = new Set(["Page", "name", "icon", "order", "variant", "showInNavigation", "showHeader"]);
30
32
  if (keys.some(key => !allowed.has(key))) {
31
33
  throw new TypeError("Page definition 包含不支持的字段");
32
34
  }
@@ -37,6 +39,9 @@ export function definePage(definition) {
37
39
  if (!PAGE_VARIANTS.has(definition.variant)) {
38
40
  throw new TypeError("Page variant 必须是 full 或 centered");
39
41
  }
42
+ if (definition.icon !== undefined && !PAGE_ICONS.has(definition.icon)) {
43
+ throw new TypeError('Page icon 必须来自 SDK 统一图标目录');
44
+ }
40
45
  if (definition.order !== undefined && !Number.isSafeInteger(definition.order)) {
41
46
  throw new TypeError("Page order 必须是安全整数");
42
47
  }
@@ -52,6 +57,7 @@ export function definePage(definition) {
52
57
  return Object.freeze({
53
58
  Page: definition.Page,
54
59
  name,
60
+ ...(definition.icon === undefined ? {} : { icon: definition.icon }),
55
61
  ...(definition.order === undefined ? {} : { order: definition.order }),
56
62
  type: "page",
57
63
  variant: definition.variant,
@@ -61,6 +61,9 @@ export interface FormHandle<S extends FormSchema> {
61
61
  SubmitButton: ComponentType<{
62
62
  children?: string;
63
63
  disabled?: boolean;
64
+ icon?: import("../ui/index.js").AppIconName;
65
+ iconOnly?: boolean;
66
+ label?: string;
64
67
  }>;
65
68
  TextArea: ComponentType<{
66
69
  name: FieldName<S, "string">;
@@ -148,7 +148,7 @@ async function assertTypeScriptProgram(projectDirectory, { sourceFiles, serverFu
148
148
  const diagnostics = ts.getPreEmitDiagnostics(program);
149
149
  if (diagnostics.length === 0) return;
150
150
  const diagnostic = diagnostics[0];
151
- const contractLine = diagnostic.file?.fileName === contractPath
151
+ const contractLine = diagnostic.file && resolve(diagnostic.file.fileName) === contractPath
152
152
  ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start || 0).line : -1;
153
153
  const contractOwner = contractLine > 0 ? handlers[Math.floor((contractLine - 1) / 2)] : null;
154
154
  const location = contractOwner ? `src/${contractOwner.relativePath}(参数/返回类型)` : diagnostic.file
@@ -314,7 +314,7 @@ function createSdkBuildPlugin(projectDirectory, sources, target) {
314
314
  };
315
315
  export const TableCell = Object.defineProperties(props => createElement(ui.TableCell, props), { Avatar: { get: () => ui.Avatar }, Badge: { value: Badge } });
316
316
  export const Table = Object.defineProperties(props => createElement(ui.Table, props), { Header: { get: () => ui.Table.Header }, HeaderCell: { get: () => ui.Table.HeaderCell }, Body: { get: () => ui.Table.Body }, Row: { get: () => ui.Table.Row }, Cell: { value: TableCell } });
317
- export const { ${[...publicComponentNames.filter(name => !["TextBlock", "Badge", "Table", "TableCell"].includes(name)), "Typography", "Widget"].join(", ")} } = ui;
317
+ export const { ${[...publicComponentNames.filter(name => !["TextBlock", "Badge", "Table", "TableCell"].includes(name)), "Typography", "Widget", "useConversation"].join(", ")} } = ui;
318
318
  ` }));
319
319
  }
320
320
  if (target !== "remote-client") buildApi.onResolve({ filter: /^@77-zhike\/app-sdk\/(client|server)$/ }, (args) => ({
@@ -289,7 +289,7 @@ function parseWorkspacePage(sourceFile, relativePath, slug) {
289
289
  const object = requireObjectLiteral(call.arguments[0], relativePath);
290
290
  assertOnlyProperties(
291
291
  object,
292
- ["name", "order", "variant", "Page", "showInNavigation", "showHeader"],
292
+ ["name", "icon", "order", "variant", "Page", "showInNavigation", "showHeader"],
293
293
  relativePath,
294
294
  "zhike_page_definition_invalid",
295
295
  "Page definition 包含不支持的字段",
@@ -314,6 +314,11 @@ function parseWorkspacePage(sourceFile, relativePath, slug) {
314
314
  `${relativePath}: variant 必须是 full 或 centered`,
315
315
  );
316
316
  }
317
+ const iconProperty = findProperty(object, "icon");
318
+ const icon = iconProperty ? requireStringProperty(object, "icon", relativePath) : undefined;
319
+ if (icon !== undefined && !SDK_ICON_NAMES.has(icon)) {
320
+ throw toolchainError("zhike_page_icon_invalid", `${relativePath}: icon 必须来自 SDK 统一图标目录`);
321
+ }
317
322
  const orderProperty = findProperty(object, "order");
318
323
  const order = orderProperty === undefined
319
324
  ? undefined
@@ -353,6 +358,7 @@ function parseWorkspacePage(sourceFile, relativePath, slug) {
353
358
  entry: slug,
354
359
  relativePath,
355
360
  name,
361
+ ...(icon === undefined ? {} : { icon }),
356
362
  ...(order === undefined ? {} : { order }),
357
363
  ...(showInNavigation === undefined ? {} : { showInNavigation }),
358
364
  ...(showHeader === undefined ? {} : { showHeader }),