@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 +9 -1
- package/package.json +2 -2
- package/src/cli/browser-auth.js +64 -18
- package/src/cli/credential-store.js +21 -0
- package/src/cli/local-commands.js +2 -1
- package/src/cli/windows-credential-bridge.ps1 +131 -0
- package/src/cli/windows-credential-store.js +112 -0
- package/src/client/index.d.ts +4 -0
- package/src/client/index.js +7 -1
- package/src/forms/index.d.ts +3 -0
- package/src/toolchain/real-build.js +2 -2
- package/src/toolchain/source-discovery.js +7 -1
- package/src/ui/components.generated.js +3 -3
- package/src/ui/conversation.d.ts +48 -0
- package/src/ui/conversation.js +82 -0
- package/src/ui/factory.js +19 -1
- package/src/ui/index.d.ts +35 -10
- package/src/ui/index.js +1 -1
- package/src/ui/scroll-viewport.d.ts +22 -0
- package/src/ui/scroll-viewport.js +23 -0
- package/src/ui/text-selection.d.ts +10 -0
- package/src/ui/text-selection.js +9 -0
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.
|
|
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": "
|
|
70
|
+
"gitHead": "cd6cf8462837c082eca5c5ccf2fb1dab8441885c"
|
|
71
71
|
}
|
package/src/cli/browser-auth.js
CHANGED
|
@@ -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
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
57
|
+
await persistRefreshCredential(api, token.refresh_credential, credentialStore);
|
|
49
58
|
return renewableClient(api, token.access_token, credentialStore);
|
|
50
59
|
} finally {
|
|
51
|
-
await callback
|
|
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
|
|
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
|
-
? [
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/src/client/index.d.ts
CHANGED
|
@@ -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 回调打开;普通关闭完成,失效/失败拒绝,不支持嵌套。 */
|
package/src/client/index.js
CHANGED
|
@@ -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,
|
package/src/forms/index.d.ts
CHANGED
|
@@ -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
|
|
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 }),
|