icuewidget-cli 0.4.45 → 0.4.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icuewidget-cli",
3
- "version": "0.4.45",
3
+ "version": "0.4.48",
4
4
  "description": "Create, validate, update, and package CORSAIR iCUE widgets.",
5
5
  "keywords": [
6
6
  "corsair",
package/src-node/cli.js CHANGED
@@ -19,6 +19,8 @@ import { notifyAboutUpdate, runUpdateCheckWorker } from "./update-check.js";
19
19
  import { ValidationError, validateWidget } from "./validation.js";
20
20
  import { createPackage } from "./zip.js";
21
21
 
22
+ const COMMANDS = ["init", "validate", "update", "package"];
23
+
22
24
  export async function main(argv = process.argv) {
23
25
  try {
24
26
  const args = argv.slice(2);
@@ -30,7 +32,9 @@ export async function main(argv = process.argv) {
30
32
  printHelp();
31
33
  return;
32
34
  }
33
- if (has(args, "--version") || has(args, "-v")) {
35
+ // `update <widget> --version <semver>` uses --version as a subcommand option, so only
36
+ // treat it as the global flag when no subcommand leads the argument list.
37
+ if (!COMMANDS.includes(args[0]) && (has(args, "--version") || has(args, "-v"))) {
34
38
  console.log(CLI_VERSION);
35
39
  return;
36
40
  }
@@ -1,6 +1,6 @@
1
1
  export const APP_NAME = "icuewidget";
2
2
  export const NPM_PACKAGE_NAME = "icuewidget-cli";
3
- export const CLI_VERSION = "0.4.45";
3
+ export const CLI_VERSION = "0.4.48";
4
4
  export const DEFAULT_APP_VERSION = "5.45";
5
5
  export const DEFAULT_FRAMEWORK_VERSION = "1.0.0";
6
6
  export const DEFAULT_PLATFORM = "windows";
@@ -13,6 +13,21 @@ export const ALLOWED_DEVICE_TYPES = new Set([
13
13
  "keyboard_lcd",
14
14
  ]);
15
15
 
16
+ // Control types accepted by iCUE for x-icue-property meta parameters (data-type).
17
+ // Anything else makes iCUE reject the widget with "Invalid meta parameter data type".
18
+ export const ALLOWED_PROPERTY_TYPES = new Set([
19
+ "slider",
20
+ "switch",
21
+ "color",
22
+ "combobox",
23
+ "search-combobox",
24
+ "tab-buttons",
25
+ "textfield",
26
+ "media-selector",
27
+ "sensors-combobox",
28
+ "sensors-factory",
29
+ ]);
30
+
16
31
  export const SEMVER_RE = /^\d+\.\d+\.\d+$/;
17
32
  export const APP_VERSION_RE = /^\d+\.\d+(?:\.\d+)?$/;
18
33
  export const IDENTIFIER_RE = /^[A-Za-z0-9.-]+$/;
@@ -10,10 +10,30 @@ function reader() {
10
10
 
11
11
  export async function prompt(label, defaultValue = "") {
12
12
  const suffix = defaultValue ? ` (${defaultValue})` : "";
13
- const answer = (await reader().question(`${label}${suffix}: `)).trim();
13
+ const answer = (await ask(`${label}${suffix}: `, label)).trim();
14
14
  return answer || defaultValue;
15
15
  }
16
16
 
17
+ // readline never settles a pending question when stdin ends (closed pipe, CI, /dev/null), so
18
+ // Node would exit 0 with the command half-run. Reject instead so the failure is visible.
19
+ function ask(question, label) {
20
+ const rl = reader();
21
+ return new Promise((resolve, reject) => {
22
+ const onClose = () => reject(new Error(`Input ended while waiting for "${label}". Pass the value as a flag or run interactively.`));
23
+ rl.once("close", onClose);
24
+ rl.question(question).then(
25
+ (answer) => {
26
+ rl.off("close", onClose);
27
+ resolve(answer);
28
+ },
29
+ (error) => {
30
+ rl.off("close", onClose);
31
+ reject(error);
32
+ },
33
+ );
34
+ });
35
+ }
36
+
17
37
  export async function promptValidated(label, defaultValue, validator, errorMessage) {
18
38
  while (true) {
19
39
  const answer = await prompt(label, defaultValue);
@@ -15,21 +15,34 @@ export function translationTemplate(widgetName) {
15
15
  }
16
16
 
17
17
  export function indexTemplate(widgetName) {
18
- return `<!doctype html>
18
+ // The widget title and the first property group share the widget name so the group
19
+ // merges with the default settings panel. tr() marks both for translation.
20
+ const titleExpr = `tr('${escapeSingleQuoted(widgetName)}')`;
21
+ const groups = renderGroups([
22
+ { title: titleExpr, properties: ["headline", "showSubtitle", "subtitle"] },
23
+ {
24
+ title: "tr('Widget Personalization')",
25
+ properties: ["textColor", "accentColor", "backgroundColor", "transparency"],
26
+ },
27
+ ]);
28
+ return `<!DOCTYPE html>
19
29
  <html lang="en">
20
30
  <head>
21
- <meta charset="utf-8">
22
- <meta name="viewport" content="width=device-width, initial-scale=1">
23
- <title>${escapeHtml(widgetName)}</title>
24
- <link rel="icon" href="resources/icon.svg" type="image/svg+xml">
25
- <link rel="stylesheet" href="styles/main.css">
26
- <meta name="x-icue-property" content='{"name":"headline","label":"Headline","type":"text","default":"Hello World"}'>
27
- <meta name="x-icue-property" content='{"name":"showSubtitle","label":"Show Subtitle","type":"boolean","default":true}'>
28
- <meta name="x-icue-property" content='{"name":"subtitle","label":"Subtitle","type":"text","default":"Built with iCUE Widget CLI"}'>
29
- <meta name="x-icue-property" content='{"name":"textColor","label":"Text Color","type":"color","default":"#ffffff"}'>
30
- <meta name="x-icue-property" content='{"name":"accentColor","label":"Accent Color","type":"color","default":"#59c2ff"}'>
31
- <meta name="x-icue-property" content='{"name":"backgroundColor","label":"Background","type":"color","default":"#111827"}'>
32
- <meta name="x-icue-property" content='{"name":"transparency","label":"Transparency","type":"number","min":0,"max":100,"default":20}'>
31
+ <meta charset="utf-8" />
32
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
33
+ <title>${escapeHtml(titleExpr)}</title>
34
+ <link rel="icon" href="resources/icon.svg" type="image/svg+xml" />
35
+ <link rel="stylesheet" href="styles/main.css" />
36
+ <meta name="x-icue-property" content="headline" data-label="tr('Headline')" data-type="textfield" data-default="'Hello World'" />
37
+ <meta name="x-icue-property" content="showSubtitle" data-label="tr('Show Subtitle')" data-type="switch" data-default="true" />
38
+ <meta name="x-icue-property" content="subtitle" data-label="tr('Subtitle')" data-type="textfield" data-default="'Built with iCUE Widget CLI'" />
39
+ <meta name="x-icue-property" content="textColor" data-label="tr('Text Color')" data-type="color" data-default="'#ffffff'" />
40
+ <meta name="x-icue-property" content="accentColor" data-label="tr('Accent Color')" data-type="color" data-default="'#59c2ff'" />
41
+ <meta name="x-icue-property" content="backgroundColor" data-label="tr('Background')" data-type="color" data-default="'#111827'" />
42
+ <meta name="x-icue-property" content="transparency" data-label="tr('Transparency')" data-type="slider" data-default="20" data-min="0" data-max="100" data-step="1" data-unit-label="'%'" />
43
+ <script id="x-icue-groups" type="application/json">
44
+ ${groups}
45
+ </script>
33
46
  </head>
34
47
  <body>
35
48
  <main id="content">
@@ -38,6 +51,16 @@ export function indexTemplate(widgetName) {
38
51
  <p id="subtitle">Built with iCUE Widget CLI</p>
39
52
  </main>
40
53
  <script src="scripts/main.js"></script>
54
+ <script>
55
+ icueEvents = {
56
+ onICUEInitialized: function(payload) { applySettings(payload && payload.settings); },
57
+ onDataUpdated: function(payload) { applySettings(payload && payload.settings); },
58
+ };
59
+
60
+ if (iCUE_initialized) {
61
+ applySettings();
62
+ }
63
+ </script>
41
64
  </body>
42
65
  </html>
43
66
  `;
@@ -115,15 +138,6 @@ function applySettings(settings = {}) {
115
138
  subtitle.hidden = !state.showSubtitle;
116
139
  }
117
140
 
118
- window.icueEvents = {
119
- onICUEInitialized(payload) {
120
- applySettings(payload?.settings);
121
- },
122
- onDataUpdated(payload) {
123
- applySettings(payload?.settings);
124
- },
125
- };
126
-
127
141
  applySettings();
128
142
  `;
129
143
 
@@ -149,3 +163,27 @@ function escapeHtml(value) {
149
163
  .replaceAll(">", "&gt;")
150
164
  .replaceAll('"', "&quot;");
151
165
  }
166
+
167
+ // Escape a value for embedding inside a single-quoted JavaScript string, e.g. tr('...').
168
+ function escapeSingleQuoted(value) {
169
+ return String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'");
170
+ }
171
+
172
+ // Render the x-icue-groups JSON body. Each group's title/info are already valid JS
173
+ // expressions (e.g. tr('...')) and are serialized as JSON strings; properties stay inline.
174
+ function renderGroups(groups) {
175
+ const body = groups
176
+ .map((group) => {
177
+ const props = group.properties.map((name) => JSON.stringify(name)).join(", ");
178
+ const lines = [
179
+ " {",
180
+ ` "title": ${JSON.stringify(group.title)},`,
181
+ ` "properties": [${props}]${group.info ? "," : ""}`,
182
+ ];
183
+ if (group.info) lines.push(` "info": ${JSON.stringify(group.info)}`);
184
+ lines.push(" }");
185
+ return lines.join("\n");
186
+ })
187
+ .join(",\n");
188
+ return ` [\n${body}\n ]`;
189
+ }
@@ -4,6 +4,7 @@ import vm from "node:vm";
4
4
  import {
5
5
  ALLOWED_DEVICE_TYPES,
6
6
  ALLOWED_PLATFORMS,
7
+ ALLOWED_PROPERTY_TYPES,
7
8
  APP_VERSION_RE,
8
9
  IDENTIFIER_RE,
9
10
  SEMVER_RE,
@@ -22,7 +23,7 @@ export async function validateWidget(widgetDir) {
22
23
  }
23
24
  }
24
25
 
25
- const manifest = await validateManifest(root);
26
+ const manifest = await validateManifest(root, warnings);
26
27
  if (await pathExists(path.join(root, "translation.json"))) {
27
28
  await readJson(path.join(root, "translation.json"));
28
29
  } else {
@@ -33,7 +34,7 @@ export async function validateWidget(widgetDir) {
33
34
  return { widgetDir: root, manifest, warnings };
34
35
  }
35
36
 
36
- async function validateManifest(root) {
37
+ async function validateManifest(root, warnings) {
37
38
  const manifest = await readJson(path.join(root, "manifest.json"));
38
39
  if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
39
40
  throw new ValidationError("manifest.json must contain a JSON object");
@@ -77,6 +78,18 @@ async function validateManifest(root) {
77
78
  if (manifest.interactive !== undefined && typeof manifest.interactive !== "boolean") {
78
79
  throw new ValidationError("manifest.interactive must be a boolean when provided");
79
80
  }
81
+ if (manifest.interactive === true) {
82
+ const nonDashboardTypes = manifest.supported_devices
83
+ .map((d) => d.type)
84
+ .filter((t) => t !== "dashboard_lcd");
85
+ if (nonDashboardTypes.length > 0) {
86
+ warnings.push(
87
+ `manifest.interactive is true, but supported_devices includes ${nonDashboardTypes.join(", ")}. ` +
88
+ `iCUE only recognizes interactive widgets on dashboard_lcd (Xeneon Edge). ` +
89
+ `The widget will not appear for ${nonDashboardTypes.join(" or ")} devices.`
90
+ );
91
+ }
92
+ }
80
93
  if (manifest.required_plugins !== undefined) validateStringArray(manifest.required_plugins, "manifest.required_plugins");
81
94
  if (manifest.modules !== undefined) {
82
95
  validateStringArray(manifest.modules, "manifest.modules");
@@ -90,15 +103,98 @@ async function validateIndex(root, warnings) {
90
103
  const html = await fs.readFile(path.join(root, "index.html"), "utf8");
91
104
  if (!/<title>[^<]+<\/title>/i.test(html)) warnings.push("index.html does not define a <title>.");
92
105
  if (!/rel=["'][^"']*icon/i.test(html)) warnings.push("index.html does not reference a favicon/icon.");
93
- if (!html.includes("icueEvents")) warnings.push("index.html or scripts do not reference icueEvents.");
94
106
 
107
+ validateHeadWellFormed(html);
108
+ validatePropertyTypes(html);
109
+ validatePropertyGroups(html);
110
+
111
+ let referencesIcueEvents = html.includes("icueEvents");
95
112
  const assetRefs = [...extractAttributes(html, "href"), ...extractAttributes(html, "src")];
96
113
  for (const ref of assetRefs) {
97
114
  if (isRemote(ref) || ref.startsWith("data:") || ref.startsWith("#")) continue;
98
115
  await validateRelativeFile(root, stripQuery(ref), `index.html asset reference ${ref}`);
99
116
  if (ref.endsWith(".css")) await validateCss(path.join(root, stripQuery(ref)));
100
- if (ref.endsWith(".js")) await validateJs(path.join(root, stripQuery(ref)));
117
+ if (ref.endsWith(".js")) {
118
+ const code = await validateJs(path.join(root, stripQuery(ref)));
119
+ if (code.includes("icueEvents")) referencesIcueEvents = true;
120
+ }
101
121
  }
122
+ if (!referencesIcueEvents) warnings.push("index.html or scripts do not reference icueEvents.");
123
+ }
124
+
125
+ // iCUE imports a widget by parsing <head> with a strict XML parser (Qt QDomDocument).
126
+ // HTML5 void elements written without a self-closing slash (e.g. `<meta charset="utf-8">`)
127
+ // are well-formed HTML but invalid XML: the parser never closes the tag, fails at </head>,
128
+ // and reports "Missing Title Element" because <title> is never reached. Catch it here so
129
+ // the widget fails validation instead of silently producing a package iCUE cannot import.
130
+ const VOID_ELEMENTS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"];
131
+
132
+ function validateHeadWellFormed(html) {
133
+ const headMatch = /<head\b[^>]*>([\s\S]*?)<\/head>/i.exec(html);
134
+ if (!headMatch) return;
135
+ const head = headMatch[1]
136
+ .replace(/<!--[\s\S]*?-->/g, "")
137
+ .replace(/<script\b[\s\S]*?<\/script>/gi, "")
138
+ .replace(/<style\b[\s\S]*?<\/style>/gi, "");
139
+ const re = new RegExp(`<(${VOID_ELEMENTS.join("|")})\\b[^>]*?>`, "gi");
140
+ let match;
141
+ while ((match = re.exec(head))) {
142
+ if (!match[0].endsWith("/>")) {
143
+ throw new ValidationError(
144
+ `index.html <head> has a non-self-closed <${match[1].toLowerCase()}> element: ${match[0].trim()}. ` +
145
+ `iCUE parses the head as XML on import, so void elements must self-close (for example <${match[1].toLowerCase()} ... />). ` +
146
+ `Otherwise iCUE rejects the widget with "Missing Title Element".`
147
+ );
148
+ }
149
+ }
150
+ }
151
+
152
+ // Each x-icue-property meta declares a control via data-type. iCUE rejects the widget
153
+ // with "Invalid meta parameter data type" if the value is not a known control type, so
154
+ // catch unknown types (e.g. "text" instead of "textfield", "boolean" instead of "switch")
155
+ // at validation time instead of on import.
156
+ function validatePropertyTypes(html) {
157
+ const metaRe = /<meta\b[^>]*\bname=["']x-icue-property["'][^>]*>/gi;
158
+ let meta;
159
+ while ((meta = metaRe.exec(html))) {
160
+ const tag = meta[0];
161
+ const typeMatch = /\bdata-type=["']([^"']*)["']/i.exec(tag);
162
+ if (!typeMatch) {
163
+ throw new ValidationError(`x-icue-property is missing a data-type attribute: ${tag.trim()}`);
164
+ }
165
+ const type = typeMatch[1].trim();
166
+ if (!ALLOWED_PROPERTY_TYPES.has(type)) {
167
+ throw new ValidationError(
168
+ `x-icue-property has an invalid data-type "${type}". ` +
169
+ `iCUE accepts: ${[...ALLOWED_PROPERTY_TYPES].join(", ")}. ` +
170
+ `(For example use "textfield" for text input and "switch" for a boolean toggle.)`
171
+ );
172
+ }
173
+ }
174
+ }
175
+
176
+ // The x-icue-groups script organizes property controls into settings panels. iCUE
177
+ // JSON-parses it, so malformed JSON or an empty group silently drops the panel. Catch
178
+ // it here. Group title/info are JS expressions (e.g. tr('...')) but are always JSON
179
+ // strings, so the block as a whole stays valid JSON.
180
+ function validatePropertyGroups(html) {
181
+ const match = /<script\b[^>]*\bid=["']x-icue-groups["'][^>]*>([\s\S]*?)<\/script>/i.exec(html);
182
+ if (!match) return;
183
+ let groups;
184
+ try {
185
+ groups = JSON.parse(match[1]);
186
+ } catch (error) {
187
+ throw new ValidationError(`x-icue-groups is not valid JSON: ${error.message}`);
188
+ }
189
+ if (!Array.isArray(groups)) throw new ValidationError("x-icue-groups must be a JSON array of groups.");
190
+ groups.forEach((group, index) => {
191
+ if (!group || typeof group !== "object" || Array.isArray(group)) {
192
+ throw new ValidationError(`x-icue-groups[${index}] must be an object.`);
193
+ }
194
+ if (!Array.isArray(group.properties) || group.properties.length === 0) {
195
+ throw new ValidationError(`x-icue-groups[${index}] must list at least one property.`);
196
+ }
197
+ });
102
198
  }
103
199
 
104
200
  function* extractAttributes(html, attribute) {
@@ -135,6 +231,7 @@ async function validateJs(filePath) {
135
231
  } catch (error) {
136
232
  throw new ValidationError(`${path.basename(filePath)} has invalid JavaScript: ${error.message}`);
137
233
  }
234
+ return code;
138
235
  }
139
236
 
140
237
  async function validateRelativeFile(root, relativePath, label) {