@aiot-nexus/plugin-sdk 1.0.1 → 1.1.0

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.
@@ -0,0 +1,379 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.pluginWebDevServerUrl = exports.createPluginWebDevServer = exports.runPluginWebDev = void 0;
27
+ const child_process_1 = require("child_process");
28
+ const fs = __importStar(require("fs"));
29
+ const http = __importStar(require("http"));
30
+ const path = __importStar(require("path"));
31
+ const dev_1 = require("./dev");
32
+ const plugin_framework_version_1 = require("../.generated/plugin-framework-version");
33
+ const loopbackHost = "127.0.0.1";
34
+ const defaultBodyLimit = 1024 * 1024;
35
+ const browserApiPrefix = "/__nexus_plugin_dev";
36
+ const pluginAssetPrefix = "/plugin/";
37
+ const pluginContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'";
38
+ const hostContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'";
39
+ function runPluginWebDev(options, io) {
40
+ const projectDirectory = path.resolve(options.projectDirectory);
41
+ let packageKind = "auto";
42
+ if (options.build) {
43
+ io.stdout("Building plugin UI with development source maps: " + projectDirectory);
44
+ packageKind = (0, dev_1.buildPluginRendererForDevelopment)(projectDirectory);
45
+ if (packageKind === "package") {
46
+ io.stdout("Plugin project has no build:web script; using its full build output for compatibility.");
47
+ }
48
+ }
49
+ const target = (0, dev_1.preparePluginBrowserDebugTarget)({ ...options, projectDirectory }, packageKind);
50
+ const server = createPluginWebDevServer(target, io);
51
+ server.once("error", (error) => {
52
+ io.stderr("Plugin browser dev host failed: " + error.message);
53
+ process.exitCode = 1;
54
+ });
55
+ server.listen(options.port, loopbackHost, () => {
56
+ const url = pluginWebDevServerUrl(server);
57
+ io.stdout("Plugin browser UI host: " + url
58
+ + "\nPlugin package: " + target.packageDirectory
59
+ + "\nContribution: " + target.surface + ":" + target.contributionId
60
+ + "\nMain entry: disabled in browser UI mode");
61
+ if (options.openBrowser) {
62
+ openDefaultBrowser(url, io);
63
+ }
64
+ });
65
+ return 0;
66
+ }
67
+ exports.runPluginWebDev = runPluginWebDev;
68
+ function createPluginWebDevServer(target, io) {
69
+ const hostHtmlPath = path.join(resolvePackageRoot(), "dev-host", "host.html");
70
+ if (!fs.existsSync(hostHtmlPath) || !fs.statSync(hostHtmlPath).isFile()) {
71
+ throw new dev_1.PluginDevError("Plugin SDK browser host is missing: " + hostHtmlPath);
72
+ }
73
+ const runtime = { settingsValues: {} };
74
+ const server = http.createServer((request, response) => {
75
+ void handleBrowserRequest(server, target, runtime, hostHtmlPath, request, response, io).catch((error) => {
76
+ const statusCode = isBrowserRequestError(error) ? error.statusCode : 500;
77
+ if (!response.headersSent) {
78
+ sendJson(response, statusCode, {
79
+ error: statusCode === 500 ? "Browser host request failed." : errorMessage(error)
80
+ });
81
+ }
82
+ else {
83
+ response.destroy();
84
+ }
85
+ if (statusCode === 500) {
86
+ io.stderr("Plugin browser host request failed: " + errorMessage(error));
87
+ }
88
+ });
89
+ });
90
+ server.on("clientError", (_error, socket) => {
91
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
92
+ });
93
+ return server;
94
+ }
95
+ exports.createPluginWebDevServer = createPluginWebDevServer;
96
+ function pluginWebDevServerUrl(server) {
97
+ const address = server.address();
98
+ if (!address || typeof address === "string") {
99
+ throw new dev_1.PluginDevError("Plugin browser dev host is not listening on TCP.");
100
+ }
101
+ return "http://" + loopbackHost + ":" + String(address.port);
102
+ }
103
+ exports.pluginWebDevServerUrl = pluginWebDevServerUrl;
104
+ async function handleBrowserRequest(server, target, runtime, hostHtmlPath, request, response, io) {
105
+ if (!requestMatchesServer(request, server)) {
106
+ sendJson(response, 403, { error: "Unexpected Host header." });
107
+ return;
108
+ }
109
+ const method = request.method || "GET";
110
+ const requestUrl = new URL(request.url || "/", pluginWebDevServerUrl(server));
111
+ const pathname = requestUrl.pathname;
112
+ if ((pathname === "/" || pathname === "/index.html") && (method === "GET" || method === "HEAD")) {
113
+ sendFile(response, hostHtmlPath, method === "HEAD", hostContentSecurityPolicy);
114
+ return;
115
+ }
116
+ if (pathname === "/favicon.ico" && (method === "GET" || method === "HEAD")) {
117
+ response.writeHead(204, commonHeaders());
118
+ response.end();
119
+ return;
120
+ }
121
+ if (pathname === browserApiPrefix + "/config" && method === "GET") {
122
+ sendJson(response, 200, createBrowserConfiguration(target, pluginWebDevServerUrl(server)));
123
+ return;
124
+ }
125
+ if (pathname === browserApiPrefix + "/invoke" && method === "POST") {
126
+ if (!requestOriginAllowed(request, pluginWebDevServerUrl(server))) {
127
+ sendJson(response, 403, { error: "Unexpected request origin." });
128
+ return;
129
+ }
130
+ const requestBody = await readJsonBody(request);
131
+ sendJson(response, 200, invokeBrowserHost(target, runtime, requestBody, io));
132
+ return;
133
+ }
134
+ if (pathname.startsWith(pluginAssetPrefix) && (method === "GET" || method === "HEAD")) {
135
+ const rendererFile = resolveRendererFile(target, pathname.slice(pluginAssetPrefix.length));
136
+ if (!rendererFile) {
137
+ sendJson(response, 404, { error: "Plugin renderer asset was not found." });
138
+ return;
139
+ }
140
+ sendFile(response, rendererFile, method === "HEAD", pluginContentSecurityPolicy);
141
+ return;
142
+ }
143
+ sendJson(response, 404, { error: "Not found." });
144
+ }
145
+ function createBrowserConfiguration(target, origin) {
146
+ const encodedEntry = target.manifest.rendererEntry.split("/").map(encodeURIComponent).join("/");
147
+ const query = new URLSearchParams({
148
+ surface: target.surface,
149
+ contributionId: target.contributionId
150
+ });
151
+ return {
152
+ pluginId: target.pluginId,
153
+ frameOrigin: origin,
154
+ frameUrl: origin + pluginAssetPrefix + encodedEntry + "?" + query.toString(),
155
+ supportedCultures: target.manifest.supportedCultures,
156
+ smoke: false,
157
+ context: pluginContext(target)
158
+ };
159
+ }
160
+ function pluginContext(target) {
161
+ return {
162
+ schemaVersion: plugin_framework_version_1.PLUGIN_MANIFEST_SCHEMA_VERSION,
163
+ pluginId: target.pluginId,
164
+ surface: target.surface,
165
+ contributionId: target.contributionId,
166
+ language: target.language,
167
+ themeMode: target.themeMode,
168
+ hostApiVersion: plugin_framework_version_1.PLUGIN_FRAMEWORK_VERSION
169
+ };
170
+ }
171
+ function invokeBrowserHost(target, runtime, request, io) {
172
+ if (!isRecord(request) || typeof request.method !== "string") {
173
+ return fail("InvalidRequest", "Plugin renderer request is invalid.");
174
+ }
175
+ const params = isRecord(request.params) ? request.params : undefined;
176
+ if (request.method === "getContext") {
177
+ return ok(pluginContext(target));
178
+ }
179
+ if (request.method === "getSettings") {
180
+ if (!hasPermission(target, "settings")) {
181
+ return fail("PluginPermissionDenied", "Plugin does not declare settings permission.");
182
+ }
183
+ return ok(settingsDocument(target, runtime));
184
+ }
185
+ if (request.method === "updateSettings") {
186
+ if (!hasPermission(target, "settings")) {
187
+ return fail("PluginPermissionDenied", "Plugin does not declare settings permission.");
188
+ }
189
+ if (!params || !isRecord(params.values)) {
190
+ return fail("InvalidRequest", "Plugin renderer settings update requires values.");
191
+ }
192
+ runtime.settingsValues = { ...runtime.settingsValues, ...sanitizeJsonObject(params.values) };
193
+ return ok(settingsDocument(target, runtime));
194
+ }
195
+ if (request.method === "appendLog") {
196
+ if (!hasPermission(target, "businessLog")) {
197
+ return fail("PluginPermissionDenied", "Plugin does not declare businessLog permission.");
198
+ }
199
+ if (!params || !isLogLevel(params.level) || typeof params.message !== "string" || !params.message.trim()) {
200
+ return fail("InvalidRequest", "Plugin renderer log request is invalid.");
201
+ }
202
+ const category = "plugin:" + target.pluginId
203
+ + (typeof params.category === "string" && params.category.trim() ? ":" + params.category.trim() : "");
204
+ const entry = {
205
+ timestamp: new Date().toISOString(),
206
+ level: params.level,
207
+ category,
208
+ message: params.message.trim(),
209
+ exception: typeof params.exception === "string" && params.exception.trim() ? params.exception.trim() : undefined
210
+ };
211
+ const output = "[nexus-plugin-web][" + entry.level + "][" + category + "] " + entry.message
212
+ + (entry.exception ? "\n" + entry.exception : "");
213
+ if (entry.level === "warn" || entry.level === "error") {
214
+ io.stderr(output);
215
+ }
216
+ else {
217
+ io.stdout(output);
218
+ }
219
+ return ok(entry);
220
+ }
221
+ if (request.method === "invokeMain") {
222
+ return fail("PluginCapabilityUnsupported", "Plugin mainEntry is not executed by the browser UI debug host. Use nexus-plugin dev for Host API integration.", { operation: params && typeof params.operation === "string" ? params.operation : undefined });
223
+ }
224
+ return fail("InvalidRequest", "Plugin renderer request method is invalid.", { method: request.method });
225
+ }
226
+ function settingsDocument(target, runtime) {
227
+ return {
228
+ schemaVersion: plugin_framework_version_1.PLUGIN_MANIFEST_SCHEMA_VERSION,
229
+ pluginId: target.pluginId,
230
+ values: runtime.settingsValues
231
+ };
232
+ }
233
+ function resolveRendererFile(target, encodedRelativePath) {
234
+ let relativePath;
235
+ try {
236
+ relativePath = decodeURIComponent(encodedRelativePath);
237
+ }
238
+ catch {
239
+ return undefined;
240
+ }
241
+ if (!relativePath || relativePath.includes("\0")) {
242
+ return undefined;
243
+ }
244
+ const entryPath = path.resolve(target.packageDirectory, target.manifest.rendererEntry);
245
+ const rendererRoot = path.dirname(entryPath);
246
+ const requestedPath = path.resolve(target.packageDirectory, relativePath);
247
+ if (!isPathInside(rendererRoot, requestedPath)
248
+ || !fs.existsSync(requestedPath)
249
+ || !fs.statSync(requestedPath).isFile()
250
+ || fs.lstatSync(requestedPath).isSymbolicLink()) {
251
+ return undefined;
252
+ }
253
+ const realRoot = fs.realpathSync(rendererRoot);
254
+ const realPath = fs.realpathSync(requestedPath);
255
+ return isPathInside(realRoot, realPath) ? realPath : undefined;
256
+ }
257
+ async function readJsonBody(request) {
258
+ const chunks = [];
259
+ let size = 0;
260
+ for await (const chunk of request) {
261
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
262
+ size += buffer.length;
263
+ if (size > defaultBodyLimit) {
264
+ throw browserRequestError(413, "Request body is too large.");
265
+ }
266
+ chunks.push(buffer);
267
+ }
268
+ if (chunks.length === 0) {
269
+ throw browserRequestError(400, "Request body is required.");
270
+ }
271
+ try {
272
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
273
+ }
274
+ catch {
275
+ throw browserRequestError(400, "Request body is not valid JSON.");
276
+ }
277
+ }
278
+ function sendFile(response, filePath, headOnly, contentSecurityPolicy) {
279
+ response.writeHead(200, {
280
+ ...commonHeaders(),
281
+ "Content-Type": mimeType(filePath),
282
+ "Content-Security-Policy": contentSecurityPolicy
283
+ });
284
+ if (headOnly) {
285
+ response.end();
286
+ return;
287
+ }
288
+ const stream = fs.createReadStream(filePath);
289
+ stream.on("error", () => response.destroy());
290
+ stream.pipe(response);
291
+ }
292
+ function sendJson(response, statusCode, value) {
293
+ const body = JSON.stringify(value);
294
+ response.writeHead(statusCode, {
295
+ ...commonHeaders(),
296
+ "Content-Type": "application/json; charset=utf-8",
297
+ "Content-Length": Buffer.byteLength(body)
298
+ });
299
+ response.end(body);
300
+ }
301
+ function commonHeaders() {
302
+ return {
303
+ "Cache-Control": "no-store",
304
+ "Cross-Origin-Resource-Policy": "same-origin",
305
+ "X-Content-Type-Options": "nosniff"
306
+ };
307
+ }
308
+ function requestMatchesServer(request, server) {
309
+ const expected = new URL(pluginWebDevServerUrl(server));
310
+ return request.headers.host === expected.host || request.headers.host === "localhost:" + expected.port;
311
+ }
312
+ function requestOriginAllowed(request, origin) {
313
+ return request.headers.origin === undefined || request.headers.origin === origin;
314
+ }
315
+ function hasPermission(target, permission) {
316
+ return target.manifest.permissions.includes(permission);
317
+ }
318
+ function sanitizeJsonObject(value) {
319
+ return JSON.parse(JSON.stringify(value));
320
+ }
321
+ function ok(value) {
322
+ return { ok: true, value };
323
+ }
324
+ function fail(code, message, details) {
325
+ return { ok: false, error: { code, message, details } };
326
+ }
327
+ function isRecord(value) {
328
+ return typeof value === "object" && value !== null && !Array.isArray(value);
329
+ }
330
+ function isLogLevel(value) {
331
+ return value === "debug" || value === "info" || value === "warn" || value === "error";
332
+ }
333
+ function isPathInside(rootDirectory, childPath) {
334
+ const relative = path.relative(path.resolve(rootDirectory), path.resolve(childPath));
335
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
336
+ }
337
+ function mimeType(filePath) {
338
+ const types = {
339
+ ".html": "text/html; charset=utf-8",
340
+ ".js": "text/javascript; charset=utf-8",
341
+ ".mjs": "text/javascript; charset=utf-8",
342
+ ".css": "text/css; charset=utf-8",
343
+ ".json": "application/json; charset=utf-8",
344
+ ".svg": "image/svg+xml",
345
+ ".png": "image/png",
346
+ ".jpg": "image/jpeg",
347
+ ".jpeg": "image/jpeg",
348
+ ".gif": "image/gif",
349
+ ".webp": "image/webp",
350
+ ".ico": "image/x-icon",
351
+ ".woff": "font/woff",
352
+ ".woff2": "font/woff2",
353
+ ".ttf": "font/ttf"
354
+ };
355
+ return types[path.extname(filePath).toLowerCase()] || "application/octet-stream";
356
+ }
357
+ function openDefaultBrowser(url, io) {
358
+ const command = process.platform === "win32" ? "rundll32.exe" : process.platform === "darwin" ? "open" : "xdg-open";
359
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
360
+ const child = (0, child_process_1.spawn)(command, args, { detached: true, stdio: "ignore" });
361
+ child.once("error", (error) => {
362
+ io.stderr("Default browser could not be opened: " + error.message + "\nOpen " + url + " manually.");
363
+ });
364
+ child.unref();
365
+ }
366
+ function browserRequestError(statusCode, message) {
367
+ const error = new Error(message);
368
+ error.statusCode = statusCode;
369
+ return error;
370
+ }
371
+ function isBrowserRequestError(value) {
372
+ return value instanceof Error && typeof value.statusCode === "number";
373
+ }
374
+ function resolvePackageRoot() {
375
+ return path.resolve(__dirname, "..", "..");
376
+ }
377
+ function errorMessage(error) {
378
+ return error instanceof Error ? error.message : String(error);
379
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiot-nexus/plugin-sdk",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Types, PluginFrame client, and CLI for AIoT Nexus independent plugins.",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -34,8 +34,11 @@
34
34
  ],
35
35
  "scripts": {
36
36
  "build": "node scripts/build.js",
37
- "test": "npm run build && node tests/package.test.js && node tests/cli.test.js && node tests/frame-client.test.js",
38
- "pack:sdk": "npm run build && node scripts/pack.js"
37
+ "verify:package": "node scripts/verify-package.js",
38
+ "test": "npm run build && node tests/package.test.js && node tests/cli.test.js && node tests/dev.test.js && node tests/nexus-dev.test.js && node tests/frame-client.test.js && node tests/web-dev.test.js && npm run verify:package",
39
+ "prepack": "node scripts/prepack.js",
40
+ "pack:sdk": "npm run build && npm run verify:package && node scripts/pack.js",
41
+ "publish:sdk": "npm publish"
39
42
  },
40
43
  "dependencies": {
41
44
  "ajv": "8.17.1"
@@ -6,17 +6,23 @@ AIoT Nexus independent PluginFrame plugin generated from the `minimal` template.
6
6
  npm install
7
7
  npm run typecheck
8
8
  npm run build
9
+ npm run dev:web
9
10
  npm run dev
11
+ npm run dev:nexus -- --host C:\path\to\nexus.exe
10
12
  ```
11
13
 
12
14
  The deployable plugin package is `dist/__PLUGIN_ID__/`. Copy that directory to the Nexus executable's `Plugins/__PLUGIN_ID__/` directory, then restart Nexus.
13
15
 
14
16
  The renderer calls the main-process `demo.echo` operation through `@aiot-nexus/plugin-sdk`. Run `npm run validate` after changing the manifest or output layout.
15
17
 
16
- The renderer follows `context.language` from Nexus, subscribes to in-place context updates without reloading the iframe, and includes `en-US` and `zh-CN` message catalogs. Add or edit renderer messages in `src/renderer/app.ts`; edit host-shell titles and descriptions in `src/manifest.json` resources.
18
+ The renderer follows `context.language` and `context.themeMode` from Nexus, subscribes to in-place context updates without reloading the iframe, and includes `en-US` and `zh-CN` message catalogs. The plugin page has no language or theme selector; the controls shown by `npm run dev` belong to the debug host and simulate Nexus context changes. Add or edit renderer messages in `src/renderer/app.ts`; edit host-shell titles and descriptions in `src/manifest.json` resources.
19
+
20
+ Use `npm run dev:web` for renderer-only UI work in the system browser. It runs `build:web`, writes an isolated package under `.nexus-plugin-dev/web-package/`, builds with source maps, serves only on `127.0.0.1:4173`, and opens the browser automatically. It does not execute `mainEntry` or overwrite the deployable `dist` package. Language and theme controls still simulate Nexus context, while `invokeMain` is intentionally unavailable. Pass `-- --no-open` or `-- --port <port>` when needed; use `npm run dev` for Host API integration.
21
+
22
+ Use `npm run dev:nexus -- --host C:\path\to\nexus.exe` for live development against a compiled, packaged Nexus. The CLI atomically deploys this plugin to `<exeDir>/Plugins/__PLUGIN_ID__/`. Renderer changes rebuild and reload the iframe without changing the Nexus PID; safe manifest changes rescan contributions in the same process; `mainEntry`, dependency, native-module, or restart-sensitive manifest changes rebuild the full package and restart Nexus. Failed builds keep the previous deployment, and a failed restarted host rolls back the package. Add `--no-devtools`, `--inspect[=<port>]`, `--inspect-brk[=<port>]`, `--surface`, or `--contribution-id` after the second `--` as needed.
17
23
 
18
24
  When this project was created from a local SDK archive, `vendor/aiot-nexus-plugin-sdk.tgz` is part of the source project and must be kept when the project is moved or committed.
19
25
 
20
26
  For main-process breakpoints, open this plugin directory in VS Code, set breakpoints in `src/main.ts`, and run `Debug Nexus plugin`. If child-process auto attach is disabled, start `npm run dev:debug` and select `Attach Nexus plugin main (9229)`. Renderer DevTools opens automatically, with `src/renderer/app.ts` available through the development source map. `npm run dev` rebuilds before launch; use `npm run dev -- --no-build` only when intentionally reusing the current `dist` output.
21
27
 
22
- The independent host stores data under `.nexus-plugin-dev/` and mirrors the PluginFrame protocol and current Host API surface. Run the deployable `dist/__PLUGIN_ID__/` in Nexus before delivery because the standalone host does not reproduce the full application shell or routing state.
28
+ The independent host stores data under `.nexus-plugin-dev/` and mirrors the PluginFrame protocol and current Host API surface. Its simulated Nexus application version defaults to the version recorded by the SDK build; pass `npm run dev -- --host-version <X.Y.Z>` to exercise another compatibility branch. Run the deployable `dist/__PLUGIN_ID__/` in Nexus before delivery because the standalone host does not reproduce the full application shell or routing state.
@@ -9,7 +9,10 @@ const ts = require("typescript");
9
9
  const pluginId = "__PLUGIN_ID__";
10
10
  const pluginRoot = path.resolve(__dirname, "..");
11
11
  const buildDirectory = path.join(pluginRoot, ".build");
12
- const outputDirectory = path.join(pluginRoot, "dist", pluginId);
12
+ const rendererOnly = readRendererOnlyOption(process.argv.slice(2));
13
+ const outputDirectory = rendererOnly
14
+ ? path.join(pluginRoot, ".nexus-plugin-dev", "web-package", pluginId)
15
+ : path.join(pluginRoot, "dist", pluginId);
13
16
  const sdkModuleSpecifier = "@aiot-nexus/plugin-sdk/browser";
14
17
 
15
18
  run();
@@ -17,12 +20,16 @@ run();
17
20
  function run() {
18
21
  fs.rmSync(buildDirectory, { recursive: true, force: true });
19
22
  fs.rmSync(outputDirectory, { recursive: true, force: true });
20
- runTypeScript("tsconfig.main.json");
23
+ if (!rendererOnly) {
24
+ runTypeScript("tsconfig.main.json");
25
+ }
21
26
  runTypeScript("tsconfig.renderer.json");
22
27
 
23
28
  fs.mkdirSync(path.join(outputDirectory, "renderer", "assets"), { recursive: true });
24
29
  fs.copyFileSync(path.join(pluginRoot, "src", "manifest.json"), path.join(outputDirectory, "manifest.json"));
25
- fs.copyFileSync(path.join(buildDirectory, "main", "main.js"), path.join(outputDirectory, "main.cjs"));
30
+ if (!rendererOnly) {
31
+ fs.copyFileSync(path.join(buildDirectory, "main", "main.js"), path.join(outputDirectory, "main.cjs"));
32
+ }
26
33
  fs.copyFileSync(path.join(pluginRoot, "src", "renderer", "index.html"), path.join(outputDirectory, "renderer", "index.html"));
27
34
  fs.copyFileSync(path.join(pluginRoot, "src", "renderer", "plugin.css"), path.join(outputDirectory, "renderer", "assets", "plugin.css"));
28
35
  rewriteRendererSdkImport(
@@ -31,7 +38,17 @@ function run() {
31
38
  );
32
39
  copyBrowserSdk();
33
40
  fs.rmSync(buildDirectory, { recursive: true, force: true });
34
- console.log("[" + pluginId + "] built " + outputDirectory);
41
+ console.log("[" + pluginId + "] built " + (rendererOnly ? "browser UI package " : "") + outputDirectory);
42
+ }
43
+
44
+ function readRendererOnlyOption(args) {
45
+ if (args.length === 0) {
46
+ return false;
47
+ }
48
+ if (args.length === 1 && args[0] === "--renderer-only") {
49
+ return true;
50
+ }
51
+ throw new Error("Unknown build option: " + args.join(" "));
35
52
  }
36
53
 
37
54
  function runTypeScript(configName) {
@@ -6,6 +6,7 @@ interface EchoResponse {
6
6
  }
7
7
 
8
8
  type SupportedLanguage = "en-US" | "zh-CN";
9
+ type ThemeMode = "light" | "dark" | "system";
9
10
 
10
11
  const englishMessages = {
11
12
  "document.title": "Plugin Echo",
@@ -58,8 +59,10 @@ const button = requireElement<HTMLButtonElement>("echoButton");
58
59
  const resultOutput = requireElement<HTMLOutputElement>("echoResult");
59
60
  const contextOutput = requireElement<HTMLElement>("pluginContext");
60
61
  const connectionState = requireElement<HTMLElement>("connectionState");
62
+ const systemDarkMode = window.matchMedia("(prefers-color-scheme: dark)");
61
63
 
62
64
  let language: SupportedLanguage = "en-US";
65
+ let themeMode: ThemeMode = "system";
63
66
  let pluginId = "";
64
67
  let surface = "";
65
68
  let connectionStateKey: MessageKey = "status.waiting";
@@ -77,23 +80,26 @@ input.addEventListener("keydown", (event) => {
77
80
  const stopContextUpdates = client.onContextChanged((context) => {
78
81
  pluginId = context.pluginId;
79
82
  surface = context.surface;
80
- applyLanguage(resolveLanguage(context.language));
83
+ applyContext(context);
81
84
  });
85
+ systemDarkMode.addEventListener("change", handleSystemThemeChange);
82
86
  window.addEventListener("beforeunload", () => {
83
87
  stopContextUpdates();
88
+ systemDarkMode.removeEventListener("change", handleSystemThemeChange);
84
89
  client.dispose();
85
90
  });
86
91
 
87
- applyLanguage(language);
88
-
89
92
  void client.waitForContext().then((context) => {
90
93
  pluginId = context.pluginId;
91
94
  surface = context.surface;
92
95
  connectionStateKey = "status.ready";
93
- applyLanguage(resolveLanguage(context.language));
96
+ applyContext(context);
94
97
  }).catch((error: unknown) => {
95
98
  connectionStateKey = "status.error";
96
99
  outputState = { kind: "literal", value: errorMessage(error) };
100
+ applyLanguage(language);
101
+ applyTheme(themeMode);
102
+ revealPluginContent();
97
103
  renderLocalizedState();
98
104
  });
99
105
 
@@ -133,6 +139,33 @@ function applyLanguage(nextLanguage: SupportedLanguage): void {
133
139
  client.notifyResize();
134
140
  }
135
141
 
142
+ function applyContext(context: { language: string; themeMode: ThemeMode }): void {
143
+ applyLanguage(resolveLanguage(context.language));
144
+ applyTheme(context.themeMode);
145
+ revealPluginContent();
146
+ }
147
+
148
+ function revealPluginContent(): void {
149
+ document.documentElement.dataset.contextReady = "true";
150
+ document.body.hidden = false;
151
+ client.notifyResize();
152
+ }
153
+
154
+ function applyTheme(nextThemeMode: ThemeMode): void {
155
+ themeMode = nextThemeMode;
156
+ const resolvedTheme = themeMode === "system"
157
+ ? (systemDarkMode.matches ? "dark" : "light")
158
+ : themeMode;
159
+ document.documentElement.dataset.theme = resolvedTheme;
160
+ document.documentElement.style.colorScheme = resolvedTheme;
161
+ }
162
+
163
+ function handleSystemThemeChange(): void {
164
+ if (themeMode === "system") {
165
+ applyTheme(themeMode);
166
+ }
167
+ }
168
+
136
169
  function renderLocalizedState(): void {
137
170
  contextOutput.textContent = pluginId
138
171
  ? pluginId + " / " + localizedSurface(surface)
@@ -1,12 +1,12 @@
1
1
  <!doctype html>
2
- <html lang="en-US">
2
+ <html lang="en-US" data-context-ready="false">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title data-i18n="document.title">Plugin Echo</title>
7
7
  <link rel="stylesheet" href="assets/plugin.css">
8
8
  </head>
9
- <body>
9
+ <body hidden>
10
10
  <main class="plugin-shell">
11
11
  <header class="plugin-header">
12
12
  <div>
@@ -1,5 +1,5 @@
1
1
  :root {
2
- color-scheme: light dark;
2
+ color-scheme: light;
3
3
  font-family: "Segoe UI", Arial, sans-serif;
4
4
  background: #f3f5f7;
5
5
  color: #20252b;
@@ -9,6 +9,10 @@
9
9
  box-sizing: border-box;
10
10
  }
11
11
 
12
+ :root:not([data-context-ready="true"]) .plugin-shell {
13
+ visibility: hidden;
14
+ }
15
+
12
16
  body {
13
17
  margin: 0;
14
18
  min-height: 0;
@@ -130,35 +134,38 @@ output {
130
134
  }
131
135
  }
132
136
 
133
- @media (prefers-color-scheme: dark) {
134
- :root,
135
- body {
136
- background: #16191d;
137
- color: #edf0f2;
138
- }
137
+ :root[data-theme="dark"] {
138
+ color-scheme: dark;
139
+ background: #16191d;
140
+ color: #edf0f2;
141
+ }
139
142
 
140
- .plugin-header,
141
- .echo-panel {
142
- border-color: #454b52;
143
- background: #24282d;
144
- }
143
+ :root[data-theme="dark"] body {
144
+ background: #16191d;
145
+ color: #edf0f2;
146
+ }
145
147
 
146
- p,
147
- .result-row > span {
148
- color: #aeb7c2;
149
- }
148
+ :root[data-theme="dark"] .plugin-header,
149
+ :root[data-theme="dark"] .echo-panel {
150
+ border-color: #454b52;
151
+ background: #24282d;
152
+ }
150
153
 
151
- input {
152
- border-color: #59616a;
153
- background: #191c20;
154
- color: #edf0f2;
155
- }
154
+ :root[data-theme="dark"] p,
155
+ :root[data-theme="dark"] .result-row > span {
156
+ color: #aeb7c2;
157
+ }
156
158
 
157
- .result-row {
158
- border-top-color: #454b52;
159
- }
159
+ :root[data-theme="dark"] input {
160
+ border-color: #59616a;
161
+ background: #191c20;
162
+ color: #edf0f2;
163
+ }
160
164
 
161
- .status {
162
- color: #75d6a5;
163
- }
165
+ :root[data-theme="dark"] .result-row {
166
+ border-top-color: #454b52;
167
+ }
168
+
169
+ :root[data-theme="dark"] .status {
170
+ color: #75d6a5;
164
171
  }