@flare-desktop/cli 0.1.1 → 0.1.2

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
@@ -33,6 +33,15 @@ Global options: `--project=<path>`, `--config=<path>`, `--environment=<name>`, `
33
33
 
34
34
  ## Frontend directory
35
35
 
36
+ ## Self-contained application configuration
37
+
38
+ The scaffold generates a typed C++ `Flare::Config` header at build time from `flare.config.json` and its platform,
39
+ environment and local layers. The executable does not read JSON from disk. Rebuild after changing any layer; the
40
+ build system tracks them. `flare dev` embeds the development layer and `flare build` embeds production. The `--config`
41
+ and `--environment` CLI flags select the input at build time. On Windows the generated CMake project enables
42
+ `FLARE_NATIVE_WEBVIEW` before fetching the C++ core; the CLI verifies that the native backend was enabled.
43
+ The generated project requires a CLI release containing `src/lib/embed-config-cli.js` (0.1.2 or newer). The new scaffold targets C++ core `v0.1.1`; publish that tag and its SDK release before using prebuilt mode.
44
+
36
45
  `flare build` passes `build.frontendDist` to CMake as `FLARE_FRONTEND_DIR`. The scaffolded `CMakeLists.txt` uses
37
46
  that variable for `FlareEmbedFrontend` and for the development directory. Change the config key, not the CMake
38
47
  file:
@@ -75,3 +84,19 @@ binaries are staged next to the application binary.
75
84
 
76
85
  The configuration is validated against
77
86
  [`schemas/flare.config.schema.json`](https://raw.githubusercontent.com/flare-desktop/flare/main/schemas/flare.config.schema.json).
87
+
88
+ ## Frontend templates, prebuilt SDK and codegen
89
+
90
+ `npm create flare-desktop@latest` offers JavaScript (the existing plain frontend) and TypeScript + Vite.
91
+ TypeScript projects copy version-matched `core.js` and `core.d.ts` into `frontend/`; `tsconfig.json` maps
92
+ `/core.js` to the declaration, while Vite bundles the JavaScript frontend into `dist/` before CMake embeds it.
93
+
94
+ Choose `--sdk=source` (FetchContent of core tag `v0.1.1`) or `--sdk=prebuilt` (download the matching
95
+ GitHub Release SDK archives into `vendor/flare`). The first binary SDK matrix covers Windows x64 MSVC
96
+ Debug/Release with MD/MT, Linux x64 GCC Debug/Release and macOS x64/arm64 Clang Debug/Release.
97
+ Unsupported architectures/ABIs must use source mode. The current C++ core's native webview runtime is
98
+ implemented on Windows; macOS and Linux builds remain headless. Release SDK archives do not exist until
99
+ the core v0.1.1 tag workflow finishes.
100
+
101
+ The former C++ `codegen` executable is replaced by `npx flare codegen --language=header|cpp|json` with
102
+ `--config`, `--out`, `--namespace`, `--embed-assets` and `--frontend`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flare-desktop/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Command line interface for the flare desktop toolkit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,9 +36,12 @@
36
36
  "node": ">=18"
37
37
  },
38
38
  "scripts": {
39
- "test": "node --test \"test/*.test.js\""
39
+ "test": "node --test"
40
40
  },
41
41
  "devDependencies": {
42
42
  "ajv": "^8.20.0"
43
+ },
44
+ "dependencies": {
45
+ "tar": "^7.5.22"
43
46
  }
44
47
  }
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import { buildCommand } from "./commands/build.js";
4
+ import { codegenCommand } from "./commands/codegen.js";
4
5
  import { bundleCommand } from "./commands/bundle.js";
5
6
  import { completionsCommand } from "./commands/completions.js";
6
7
  import { devCommand } from "./commands/dev.js";
@@ -24,6 +25,8 @@ async function initCommand(context) {
24
25
  identifier: context.flags.identifier,
25
26
  appVersion: context.flags.appVersion ?? "0.1.0",
26
27
  force: Boolean(context.flags.force),
28
+ template: context.flags.template || "javascript",
29
+ sdkMode: context.flags.sdk || "source",
27
30
  logger: context.logger
28
31
  });
29
32
  return 0;
@@ -35,6 +38,7 @@ const handlers = {
35
38
  build: buildCommand,
36
39
  bundle: bundleCommand,
37
40
  icon: iconCommand,
41
+ codegen: codegenCommand,
38
42
  info: infoCommand,
39
43
  inspect: inspectCommand,
40
44
  doctor: doctorCommand,
@@ -21,6 +21,8 @@ export async function buildCommand(context) {
21
21
  const { binary, directory } = await compileApplication({
22
22
  projectDirectory: context.projectDirectory,
23
23
  config,
24
+ configFile: context.flags.config || "flare.config.json",
25
+ environment: context.flags.environment,
24
26
  mode,
25
27
  logger: context.logger
26
28
  });
@@ -21,6 +21,8 @@ export async function bundleCommand(context) {
21
21
  const { binary } = await compileApplication({
22
22
  projectDirectory: context.projectDirectory,
23
23
  config,
24
+ configFile: context.flags.config || "flare.config.json",
25
+ environment: context.flags.environment,
24
26
  mode: "Release",
25
27
  logger: context.logger
26
28
  });
@@ -0,0 +1,53 @@
1
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { generateEmbeddedConfig } from "../lib/embedded-config.js";
5
+ import { FlareError } from "../lib/errors.js";
6
+ import { loadProject, mergeConfig, parseConfig, toKebabCase } from "../lib/project.js";
7
+
8
+ function filesIn(directory) {
9
+ const result = [];
10
+ const walk = (current) => {
11
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
12
+ const absolute = path.join(current, entry.name);
13
+ if (entry.isDirectory()) walk(absolute);
14
+ else if (entry.isFile()) result.push({ key: path.relative(directory, absolute).replaceAll(path.sep, "/"), size: statSync(absolute).size });
15
+ }
16
+ };
17
+ walk(directory);
18
+ return result.sort((a, b) => a.key.localeCompare(b.key));
19
+ }
20
+
21
+ const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png" };
22
+
23
+ export async function codegenCommand(context) {
24
+ const { config, files } = loadProject({
25
+ projectDirectory: context.projectDirectory,
26
+ configFile: context.flags.config,
27
+ environment: context.flags.environment
28
+ });
29
+ const output = path.resolve(context.projectDirectory, context.flags.out || "generated");
30
+ const language = context.flags.language || "header";
31
+ if (!["header", "hpp", "cpp", "source", "json"].includes(language)) throw new FlareError(`unsupported codegen language '${language}'`);
32
+ const namespace = context.flags.namespace || "FlareGenerated";
33
+ if (!/^[A-Za-z_][A-Za-z_0-9]*$/.test(namespace)) throw new FlareError("invalid C++ namespace");
34
+ mkdirSync(output, { recursive: true });
35
+ let raw = {};
36
+ for (const file of files) raw = mergeConfig(raw, parseConfig(readFileSync(file, "utf8"), file));
37
+ if (language === "json") {
38
+ const name = config.app.identifier.replace(/[^A-Za-z0-9]/g, "_");
39
+ writeFileSync(path.join(output, `${name}-config.json`), JSON.stringify(config, null, 2) + "\n");
40
+ } else {
41
+ const name = `${toKebabCase(config.app.productName)}-config.h`;
42
+ writeFileSync(path.join(output, name), generateEmbeddedConfig(config, raw, namespace));
43
+ const commands = (config.build?.removeUnusedCommands || []).map((item) => ` application.Commands().Unregister(${JSON.stringify(item)});`).join("\n");
44
+ writeFileSync(path.join(output, "commands.cpp"), `#include <flare/core/application.h>\n\nnamespace ${namespace} {\ninline void RegisterCommands(Flare::Application& application) {\n${commands}\n (void)application;\n}\n}\n`);
45
+ }
46
+ if (context.flags.embedAssets) {
47
+ const directory = path.resolve(context.projectDirectory, context.flags.frontend || config.build?.frontendDist || "frontend");
48
+ const assets = filesIn(directory).map(({ key, size }) => ({ key, size, mimeType: MIME[path.extname(key).toLowerCase()] || "application/octet-stream" }));
49
+ writeFileSync(path.join(output, "asset-manifest.json"), JSON.stringify({ count: assets.length, totalBytes: assets.reduce((a, b) => a + b.size, 0), assets }, null, 2) + "\n");
50
+ }
51
+ context.logger.info(`generated configuration in '${output}'`);
52
+ return 0;
53
+ }
@@ -61,6 +61,8 @@ export async function devCommand(context) {
61
61
  const { binary } = await compileApplication({
62
62
  projectDirectory: context.projectDirectory,
63
63
  config,
64
+ configFile: context.flags.config || "flare.config.json",
65
+ environment: context.flags.environment,
64
66
  mode: "Debug",
65
67
  logger: context.logger
66
68
  });
@@ -1,11 +1,12 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  import { FlareError } from "../lib/errors.js";
5
5
  import { placeholderIcon } from "../lib/icons.js";
6
6
  import { isValidAppName, toKebabCase } from "../lib/project.js";
7
+ import { installSdk } from "../lib/sdk.js";
7
8
  import { writePng } from "../lib/png.js";
8
- import { VERSION } from "../version.js";
9
+ import { CORE_VERSION, MIN_CLI_VERSION } from "../release-versions.js";
9
10
  import {
10
11
  cmakeTemplate,
11
12
  configTemplate,
@@ -14,6 +15,9 @@ import {
14
15
  packageJsonTemplate,
15
16
  readmeTemplate,
16
17
  scriptTemplate,
18
+ typescriptTemplate,
19
+ tsconfigTemplate,
20
+ viteConfigTemplate,
17
21
  sourceTemplate,
18
22
  styleTemplate
19
23
  } from "../templates/project.js";
@@ -28,10 +32,15 @@ export async function initProject({
28
32
  baseDirectory = ".",
29
33
  identifier,
30
34
  appVersion = "0.1.0",
31
- coreVersion = VERSION,
35
+ coreVersion = CORE_VERSION,
36
+ template = "javascript",
37
+ sdkMode = "source",
38
+ sdkFetch,
32
39
  force = false,
33
40
  logger
34
41
  }) {
42
+ if (!["javascript", "typescript"].includes(template)) throw new FlareError("template must be javascript or typescript");
43
+ if (!["source", "prebuilt"].includes(sdkMode)) throw new FlareError("SDK mode must be source or prebuilt");
35
44
  const projectName = toKebabCase(name);
36
45
  if (!isValidAppName(projectName)) {
37
46
  throw new FlareError(
@@ -58,7 +67,7 @@ export async function initProject({
58
67
  {
59
68
  file: "flare.config.json",
60
69
  content: `${JSON.stringify(
61
- configTemplate({ name: projectName, identifier: appIdentifier, version: appVersion }),
70
+ configTemplate({ name: projectName, identifier: appIdentifier, version: appVersion, template }),
62
71
  null,
63
72
  2
64
73
  )}\n`
@@ -66,23 +75,30 @@ export async function initProject({
66
75
  {
67
76
  file: "package.json",
68
77
  content: `${JSON.stringify(
69
- packageJsonTemplate({ name: projectName, version: appVersion, cliVersion: coreVersion }),
78
+ packageJsonTemplate({ name: projectName, version: appVersion, cliVersion: MIN_CLI_VERSION, template }),
70
79
  null,
71
80
  2
72
81
  )}\n`
73
82
  },
74
83
  {
75
84
  file: "CMakeLists.txt",
76
- content: cmakeTemplate({ name: projectName, version: appVersion, coreVersion })
85
+ content: cmakeTemplate({ name: projectName, version: appVersion, coreVersion, sdkMode })
77
86
  },
78
87
  {
79
88
  file: "source/main.cpp",
80
- content: sourceTemplate({ name: projectName, identifier: appIdentifier, version: appVersion })
89
+ content: sourceTemplate({ name: projectName })
81
90
  },
82
- { file: "frontend/index.html", content: indexHtmlTemplate(projectName) },
91
+ { file: "frontend/index.html", content: indexHtmlTemplate(projectName, template) },
83
92
  { file: "frontend/style.css", content: styleTemplate() },
84
- { file: "frontend/main.js", content: scriptTemplate() },
93
+ ...(template === "typescript" ? [
94
+ { file: "frontend/main.ts", content: typescriptTemplate() },
95
+ { file: "frontend/core.js", content: readFileSync(new URL("../templates/core.js", import.meta.url), "utf8") },
96
+ { file: "frontend/core.d.ts", content: readFileSync(new URL("../templates/core.d.ts", import.meta.url), "utf8") },
97
+ { file: "tsconfig.json", content: JSON.stringify(tsconfigTemplate(), null, 2) + "\n" },
98
+ { file: "vite.config.mjs", content: viteConfigTemplate() }
99
+ ] : [{ file: "frontend/main.js", content: scriptTemplate() }]),
85
100
  { file: ".gitignore", content: gitignoreTemplate() },
101
+ { file: ".clang-format", content: readFileSync(new URL("../templates/clang-format.txt", import.meta.url), "utf8") },
86
102
  { file: "README.md", content: readmeTemplate(projectName) }
87
103
  ];
88
104
 
@@ -90,6 +106,16 @@ export async function initProject({
90
106
  write(path.join(targetDirectory, entry.file), entry.content);
91
107
  }
92
108
 
109
+ if (sdkMode === "prebuilt") {
110
+ if (logger) logger.info(`downloading flare SDK v${coreVersion} for ${process.platform}/${process.arch}`);
111
+ try {
112
+ await installSdk({ destination: path.join(targetDirectory, "vendor", "flare"), version: coreVersion, ...(sdkFetch ? { fetchImpl: sdkFetch } : {}) });
113
+ } catch (error) {
114
+ if (!force) rmSync(targetDirectory, { recursive: true, force: true });
115
+ throw error;
116
+ }
117
+ }
118
+
93
119
  writeFileSync(path.join(targetDirectory, "icons", "icon.png"), writePng(placeholderIcon()));
94
120
  writeFileSync(path.join(targetDirectory, "icons", "32x32.png"), writePng(placeholderIcon(32)));
95
121
 
@@ -104,5 +130,5 @@ export async function initProject({
104
130
  logger.step("npm run dev");
105
131
  }
106
132
 
107
- return { directory: targetDirectory, name: projectName, identifier: appIdentifier, files: written };
133
+ return { directory: targetDirectory, name: projectName, identifier: appIdentifier, template, sdkMode, files: written };
108
134
  }
package/src/help.js CHANGED
@@ -11,7 +11,7 @@ export function helpText() {
11
11
  ];
12
12
 
13
13
  for (const command of COMMANDS) {
14
- lines.push(` ${command.usage.padEnd(26)}${command.description}`);
14
+ lines.push(` ${command.usage.padEnd(39)}${command.description}`);
15
15
  }
16
16
 
17
17
  lines.push(
@@ -20,6 +20,12 @@ export function helpText() {
20
20
  " --project=<path> project directory (default: .)",
21
21
  " --config=<path> configuration file (default: flare.config.json)",
22
22
  " --environment=<name> add the environment configuration layer",
23
+ " --template=<javascript|typescript> frontend template (init)",
24
+ " --sdk=<source|prebuilt> C++ dependency mode (init)",
25
+ " --language=<header|cpp|json> codegen format",
26
+ " --namespace=<name> codegen C++ namespace",
27
+ " --frontend=<path> codegen asset directory",
28
+ " --embed-assets codegen asset manifest",
23
29
  " --bundles=<list> bundle targets, for example nsis,msi,deb",
24
30
  " --out=<path> bundle output directory (default: target/bundle)",
25
31
  " --json machine readable output",
package/src/lib/args.js CHANGED
@@ -8,6 +8,11 @@ const VALUE_FLAGS = new Set([
8
8
  "format",
9
9
  "identifier",
10
10
  "app-version",
11
+ "sdk",
12
+ "template",
13
+ "language",
14
+ "namespace",
15
+ "frontend",
11
16
  "dev-timeout"
12
17
  ]);
13
18
 
package/src/lib/build.js CHANGED
@@ -10,14 +10,16 @@ export async function runHook(command, { cwd, env, logger, label }) {
10
10
  if (result.code !== 0) throw new FlareError(`${label} failed with code ${result.code}`);
11
11
  }
12
12
 
13
- export async function compileApplication({ projectDirectory, config, mode, env, logger }) {
13
+ export async function compileApplication({ projectDirectory, config, mode, env, logger, configFile, environment }) {
14
14
  const directory = buildDirectory(projectDirectory, mode);
15
15
  await configureProject(projectDirectory, {
16
16
  directory,
17
17
  mode,
18
18
  env,
19
19
  logger,
20
- frontendDirectory: frontendDirectory(projectDirectory, config)
20
+ frontendDirectory: frontendDirectory(projectDirectory, config),
21
+ configFile,
22
+ environment
21
23
  });
22
24
  await buildProject(projectDirectory, { directory, mode, env });
23
25
  const binary = findExecutable(directory, binaryName(config));
package/src/lib/cmake.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  import { FlareError } from "./errors.js";
@@ -35,7 +35,7 @@ export function buildDirectory(projectDirectory, mode) {
35
35
  return path.join(projectDirectory, "build", mode === "Debug" ? "debug" : "release");
36
36
  }
37
37
 
38
- export async function configureProject(projectDirectory, { directory, mode, env, logger, frontendDirectory }) {
38
+ export async function configureProject(projectDirectory, { directory, mode, env, logger, frontendDirectory, configFile = "flare.config.json", environment = "production" }) {
39
39
  if (!cmakeVersion()) throw new FlareError("cmake was not found in PATH");
40
40
  const outputDirectory = path.join(directory, "bin");
41
41
  const configuration = mode.toUpperCase();
@@ -50,7 +50,6 @@ export async function configureProject(projectDirectory, { directory, mode, env,
50
50
  `-DCMAKE_RUNTIME_OUTPUT_DIRECTORY_${configuration}=${outputDirectory}`,
51
51
  "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
52
52
  `-DFLARE_FRONTEND_DIR=${frontendDirectory ?? path.join(projectDirectory, "frontend")}`,
53
- "-DFLARE_BUILD_TOOLS=OFF",
54
53
  "-DFLARE_BUILD_EXAMPLES=OFF",
55
54
  "-DFLARE_BUILD_TESTS=OFF",
56
55
  "-DFLARE_INSTALL=OFF"
@@ -58,6 +57,12 @@ export async function configureProject(projectDirectory, { directory, mode, env,
58
57
  logger.debug(`cmake ${args.join(" ")}`);
59
58
  const result = await run("cmake", args, { cwd: projectDirectory, env });
60
59
  if (result.code !== 0) throw new FlareError("cmake configuration failed");
60
+ if (process.platform === "win32") {
61
+ const cache = readFileSync(path.join(directory, "CMakeCache.txt"), "utf8");
62
+ if (!/^FLARE_NATIVE_WEBVIEW:BOOL=ON$/m.test(cache)) {
63
+ throw new FlareError("Windows build has no native webview backend; configure with -DFLARE_NATIVE_WEBVIEW=ON");
64
+ }
65
+ }
61
66
  }
62
67
 
63
68
  export async function buildProject(projectDirectory, { directory, mode, target, env }) {
@@ -3,6 +3,7 @@ export const COMMANDS = [
3
3
  { name: "dev", usage: "dev", description: "build and run the application in development mode" },
4
4
  { name: "build", usage: "build [--debug]", description: "build the application" },
5
5
  { name: "bundle", usage: "bundle", description: "build and prepare the platform bundle targets" },
6
+ { name: "codegen", usage: "codegen [--language=header|json]", description: "generate a typed C++ config, JSON or an asset manifest" },
6
7
  { name: "icon", usage: "icon <source.png>", description: "generate the icon set from a square png" },
7
8
  { name: "info", usage: "info", description: "print project and platform information" },
8
9
  { name: "inspect", usage: "inspect", description: "print the resolved configuration layers" },
@@ -0,0 +1,14 @@
1
+ import { writeEmbeddedConfig } from "./embedded-config.js";
2
+
3
+ const [projectDirectory, configFile, environment, output] = process.argv.slice(2);
4
+ if (!projectDirectory || !configFile || !environment || !output) {
5
+ process.stderr.write("usage: embed-config-cli.js <project> <config> <environment> <output>\n");
6
+ process.exitCode = 1;
7
+ } else {
8
+ try {
9
+ writeEmbeddedConfig({ projectDirectory, configFile, environment, output });
10
+ } catch (error) {
11
+ process.stderr.write(`failed to embed configuration: ${error.message}\n`);
12
+ process.exitCode = 1;
13
+ }
14
+ }
@@ -0,0 +1,181 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ import { loadProject, mergeConfig, parseConfig } from "./project.js";
5
+
6
+ function cppString(value) {
7
+ const bytes = Buffer.from(value, "utf8");
8
+ let output = '"';
9
+ for (const byte of bytes) {
10
+ if (byte === 34) output += '\\"';
11
+ else if (byte === 92) output += "\\\\";
12
+ else if (byte >= 32 && byte <= 126) output += String.fromCharCode(byte);
13
+ else output += `\\${byte.toString(8).padStart(3, "0")}`;
14
+ }
15
+ return `${output}"`;
16
+ }
17
+
18
+ function cppJson(value) {
19
+ if (value === null) return "Flare::JSON(nullptr)";
20
+ if (Array.isArray(value)) return `Flare::JSON::array({${value.map(cppJson).join(", ")}})`;
21
+ if (typeof value === "object") {
22
+ const entries = Object.entries(value).map(([key, item]) => `{${cppString(key)}, ${cppJson(item)}}`);
23
+ return `Flare::JSON::object({${entries.join(", ")}})`;
24
+ }
25
+ if (typeof value === "string") return `Flare::JSON(${cppString(value)})`;
26
+ if (typeof value === "boolean") return `Flare::JSON(${value})`;
27
+ if (typeof value === "number" && Number.isFinite(value)) return `Flare::JSON(${value})`;
28
+ throw new TypeError("unsupported JSON value in configuration");
29
+ }
30
+
31
+ function scalar(value, kind) {
32
+ if (kind === "string" && typeof value === "string") return cppString(value);
33
+ if (kind === "bool" && typeof value === "boolean") return `${value}`;
34
+ if (kind === "number" && typeof value === "number" && Number.isFinite(value)) return `${value}`;
35
+ if (kind === "int" && Number.isSafeInteger(value)) return `${value}`;
36
+ if (kind === "strings" && Array.isArray(value)) {
37
+ return `{${value.filter((item) => typeof item === "string").map(cppString).join(", ")}}`;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ const FIELDS = {
43
+ package: { productName: "string", version: "string" },
44
+ app: { productName: "string", mainBinaryName: "string", version: "string", identifier: "string" },
45
+ build: {
46
+ beforeDevCommand: "string", afterDevCommand: "string", beforeBuildCommand: "string",
47
+ afterBuildCommand: "string", frontendDist: "string", devUrl: "string", removeUnusedCommands: "strings"
48
+ },
49
+ "build.features": { customProtocol: "bool", devtools: "bool", isolation: "bool", systemTray: "bool" },
50
+ bundle: {
51
+ active: "bool", identifier: "string", icon: "string", version: "string", publisher: "string",
52
+ homepage: "string", longDescription: "string", copyright: "string", category: "string",
53
+ shortDescription: "string", licenseFile: "string", targets: "strings", resources: "strings",
54
+ externalBin: "strings"
55
+ },
56
+ "bundle.linuxBundle": { depends: "string", desktopTemplate: "string", section: "string", priority: "string", files: "strings" },
57
+ "bundle.windows": { language: "string", installerIcon: "string", installMode: "int", webviewInstallModeOffline: "bool" },
58
+ "bundle.macos": { minimumSystemVersion: "string", frameworks: "bool", signingIdentity: "string" },
59
+ security: {
60
+ freezePrototype: "bool", dangerousDisableAssetCspModification: "bool", dangerousUseHttpScheme: "bool",
61
+ dangerousAcceptInvalidCertificates: "bool", capabilities: "strings", dangerousRemoteDomainIpcAccess: "strings"
62
+ },
63
+ window: {
64
+ label: "string", title: "string", resizable: "bool", maximized: "bool", fullscreen: "bool",
65
+ alwaysOnTop: "bool", decorations: "bool", transparent: "bool", visible: "bool", center: "bool",
66
+ devtools: "bool", url: "string"
67
+ }
68
+ };
69
+
70
+ function emitFields(lines, target, input, fields) {
71
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return;
72
+ for (const [key, kind] of Object.entries(fields)) {
73
+ const result = scalar(input[key], kind);
74
+ if (result !== null) lines.push(`${target}.${key} = ${result};`);
75
+ }
76
+ }
77
+
78
+ function emitSize(lines, target, input) {
79
+ if (!input || typeof input !== "object" || Array.isArray(input)) return;
80
+ for (const key of ["width", "height"]) {
81
+ const result = scalar(input[key], "number");
82
+ if (result !== null) lines.push(`${target}.${key} = ${result};`);
83
+ }
84
+ }
85
+
86
+ function emitCsp(lines, input) {
87
+ if (typeof input === "string") {
88
+ const directives = input.split(";").map((statement) => statement.trim()).filter(Boolean);
89
+ lines.push(`config.security.csp.enabled = ${input.trim() ? "true" : "false"};`);
90
+ for (const statement of directives) {
91
+ const [name, ...sources] = statement.split(/\s+/);
92
+ lines.push(`config.security.csp.directives.push_back(Flare::CspDirective{${cppString(name)}, ${scalar(sources, "strings")}});`);
93
+ }
94
+ } else if (input && typeof input === "object" && !Array.isArray(input)) {
95
+ lines.push("config.security.csp.enabled = true;");
96
+ for (const [name, sources] of Object.entries(input).sort(([a], [b]) => a.localeCompare(b))) {
97
+ const values = typeof sources === "string" ? sources.trim().split(/\s+/).filter(Boolean) : sources;
98
+ lines.push(`config.security.csp.directives.push_back(Flare::CspDirective{${cppString(name)}, ${scalar(values, "strings") ?? "{}"}});`);
99
+ }
100
+ }
101
+ }
102
+
103
+ export function generateEmbeddedConfig(config, raw = config, namespace = "FlareGenerated") {
104
+ if (!/^[A-Za-z_][A-Za-z_0-9]*$/.test(namespace)) throw new Error("invalid C++ namespace");
105
+ config = structuredClone(config);
106
+ const lines = [
107
+ "#pragma once", "", "#include <flare/config/config.h>", "", `namespace ${namespace} {`,
108
+ "inline Flare::Config EmbeddedConfig() {", "Flare::Config config;"
109
+ ];
110
+ if (raw.package && typeof raw.package === "object" && !Array.isArray(raw.package)) {
111
+ config.package.version ??= "0.1.0";
112
+ }
113
+ if (raw.build && typeof raw.build === "object" && !Array.isArray(raw.build)) {
114
+ config.build.frontendDist ??= "../dist";
115
+ }
116
+ if (raw.bundle && typeof raw.bundle === "object" && !Array.isArray(raw.bundle)) {
117
+ config.bundle.active ??= true;
118
+ config.bundle.icon ??= "icons/icon.png";
119
+ config.bundle.category ??= "DeveloperTool";
120
+ }
121
+ if (raw.bundle?.linux && typeof raw.bundle.linux === "object") {
122
+ config.bundle.linux.section ??= "utils";
123
+ config.bundle.linux.priority ??= "optional";
124
+ }
125
+ if (raw.bundle?.windows && typeof raw.bundle.windows === "object") {
126
+ config.bundle.windows.language ??= "en-US";
127
+ }
128
+ emitFields(lines, "config.package", config.package, FIELDS.package);
129
+ emitFields(lines, "config.app", config.app, FIELDS.app);
130
+ emitFields(lines, "config.build", config.build, FIELDS.build);
131
+ emitFields(lines, "config.build.features", config.build?.features, FIELDS["build.features"]);
132
+ emitFields(lines, "config.bundle", config.bundle, FIELDS.bundle);
133
+ emitFields(lines, "config.bundle.linuxBundle", config.bundle?.linux, FIELDS["bundle.linuxBundle"]);
134
+ emitFields(lines, "config.bundle.windows", config.bundle?.windows, FIELDS["bundle.windows"]);
135
+ emitFields(lines, "config.bundle.macos", config.bundle?.macos, FIELDS["bundle.macos"]);
136
+ emitFields(lines, "config.security", config.security, FIELDS.security);
137
+ emitCsp(lines, config.security?.csp);
138
+ if (typeof config.$schema === "string") lines.push(`config.schema = ${cppString(config.$schema)};`);
139
+ const windows = Array.isArray(config.windows) && config.windows.length ? config.windows : [{}];
140
+ for (const value of windows) {
141
+ lines.push("{ Flare::WindowConfig window;");
142
+ emitFields(lines, "window", value, FIELDS.window);
143
+ emitSize(lines, "window.size", value);
144
+ emitSize(lines, "window.size", value.size);
145
+ lines.push("window.minimumSize = Flare::WindowSize{0.0, 0.0};");
146
+ lines.push("window.maximumSize = Flare::WindowSize{0.0, 0.0};");
147
+ emitSize(lines, "window.minimumSize", value.minimumSize);
148
+ emitSize(lines, "window.maximumSize", value.maximumSize);
149
+ if (value.position && typeof value.position === "object" && !Array.isArray(value.position)) {
150
+ const x = scalar(value.position.x, "number") ?? "0.0";
151
+ const y = scalar(value.position.y, "number") ?? "0.0";
152
+ lines.push(`window.position = Flare::WindowPosition{${x}, ${y}};`);
153
+ }
154
+ if (typeof value.theme === "string") {
155
+ const theme = { light: "Light", dark: "Dark", automatic: "Automatic", system: "Automatic", auto: "Automatic" }[value.theme.toLowerCase()];
156
+ if (theme) lines.push(`window.theme = Flare::WindowTheme::${theme};`);
157
+ }
158
+ lines.push("config.windows.push_back(window); }");
159
+ }
160
+ if (config.plugins && typeof config.plugins === "object" && !Array.isArray(config.plugins)) {
161
+ for (const [name, value] of Object.entries(config.plugins).sort(([a], [b]) => a.localeCompare(b))) {
162
+ lines.push(`config.plugins.push_back(Flare::PluginConfig{${cppString(name)}, ${cppJson(value)}});`);
163
+ }
164
+ }
165
+ lines.push(`config.raw = ${cppJson(raw)};`);
166
+ lines.push("return config;", "}", "}", "");
167
+ return lines.join("\n");
168
+ }
169
+
170
+ export function writeEmbeddedConfig({ projectDirectory, configFile = "flare.config.json", environment = "production", output }) {
171
+ const { config, files } = loadProject({ projectDirectory, configFile, environment });
172
+ let raw = {};
173
+ for (const file of files) raw = mergeConfig(raw, parseConfig(readFileSync(file, "utf8"), file));
174
+ writeFileSync(output, generateEmbeddedConfig(config, raw), "utf8");
175
+ const result = spawnSync("clang-format", ["-style=file", "-i", output], { cwd: projectDirectory });
176
+ if (result.error && result.error.code !== "ENOENT") throw result.error;
177
+ if (!result.error && result.status !== 0) {
178
+ throw new Error(`clang-format failed: ${result.stderr.toString("utf8")}`);
179
+ }
180
+ return files;
181
+ }
@@ -175,8 +175,8 @@ export function mergeConfig(base, overlay) {
175
175
  function normalizeWindow(window, fallbackTitle) {
176
176
  const value = isObject(window) ? { ...window } : {};
177
177
  const title = value.title ?? fallbackTitle;
178
- const width = value.width ?? value.size?.width ?? 800;
179
- const height = value.height ?? value.size?.height ?? 600;
178
+ const width = value.size?.width ?? value.width ?? 800;
179
+ const height = value.size?.height ?? value.height ?? 600;
180
180
  return {
181
181
  ...value,
182
182
  label: value.label ?? "main",
@@ -212,7 +212,8 @@ export function normalizeConfig(raw) {
212
212
  if (!config.app.mainBinaryName) config.app.mainBinaryName = config.app.productName;
213
213
 
214
214
  const fallbackTitle = config.app.productName || "flare-app";
215
- const windows = Array.isArray(config.windows) && config.windows.length > 0 ? config.windows : [{}];
215
+ const sourceWindows = Array.isArray(config.windows) ? config.windows : config.app.windows;
216
+ const windows = Array.isArray(sourceWindows) && sourceWindows.length > 0 ? sourceWindows : [{}];
216
217
  config.windows = windows.map((window) => normalizeWindow(window, fallbackTitle));
217
218
 
218
219
  return config;
package/src/lib/sdk.js ADDED
@@ -0,0 +1,54 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import path from "node:path";
4
+ import { extract } from "tar";
5
+
6
+ import { FlareError } from "./errors.js";
7
+
8
+ export function sdkTarget(platform = process.platform, arch = process.arch) {
9
+ if (!(["x64", "arm64"].includes(arch))) throw new FlareError(`prebuilt SDK is not available for ${arch}; choose source mode`);
10
+ if (platform === "win32") {
11
+ if (arch !== "x64") throw new FlareError("prebuilt Windows SDK supports x64 MSVC only; choose source mode");
12
+ return { platform: "windows-x64", runtimes: ["md", "mt"] };
13
+ }
14
+ if (platform === "darwin") return { platform: `macos-${arch}`, runtimes: ["default"] };
15
+ if (platform === "linux" && arch === "x64") return { platform: "linux-x64", runtimes: ["default"] };
16
+ throw new FlareError(`prebuilt SDK is not available for ${platform}-${arch}; choose source mode`);
17
+ }
18
+
19
+ export async function installSdk({ destination, version, platform = process.platform, arch = process.arch, fetchImpl = fetch }) {
20
+ const target = sdkTarget(platform, arch);
21
+ mkdirSync(path.dirname(destination), { recursive: true });
22
+ const temporary = mkdtempSync(path.join(path.dirname(destination), ".flare-sdk-"));
23
+ const sdk = path.join(temporary, "flare");
24
+ mkdirSync(sdk);
25
+ try {
26
+ const checksumsUrl = `https://github.com/flare-desktop/flare/releases/download/v${version}/SHA256SUMS.txt`;
27
+ const checksumsResponse = await fetchImpl(checksumsUrl);
28
+ if (!checksumsResponse.ok) throw new FlareError(`SDK checksums are unavailable (HTTP ${checksumsResponse.status}); publish core v${version} first`);
29
+ const sums = new Map((await checksumsResponse.text()).split("\n").map((line) => /^(\w{64})\s+\*?(\S+)$/.exec(line)).filter(Boolean).map((match) => [match[2], match[1].toLowerCase()]));
30
+ for (const runtime of target.runtimes) {
31
+ const file = `flare-sdk-v${version}-${target.platform}-${runtime}.tar.gz`;
32
+ const url = `https://github.com/flare-desktop/flare/releases/download/v${version}/${file}`;
33
+ let response;
34
+ try { response = await fetchImpl(url); } catch (error) { throw new FlareError(`failed to download ${file}: ${error.message}`); }
35
+ if (!response.ok) throw new FlareError(`SDK archive ${file} is unavailable (HTTP ${response.status}); publish core v${version} SDK release first, or choose source mode`);
36
+ const archive = path.join(temporary, file);
37
+ const data = Buffer.from(await response.arrayBuffer());
38
+ const digest = createHash("sha256").update(data).digest("hex");
39
+ if (!sums.has(file) || sums.get(file) !== digest) throw new FlareError(`SDK checksum mismatch for ${file}`);
40
+ writeFileSync(archive, data);
41
+ await extract({ file: archive, cwd: temporary, strict: true, filter: (entry) => entry === "flare" || entry.startsWith("flare/") });
42
+ const manifest = JSON.parse(readFileSync(path.join(sdk, "sdk-manifest.json"), "utf8"));
43
+ if (manifest.version !== version || manifest.target !== target.platform || manifest.crt !== runtime) {
44
+ throw new FlareError(`SDK archive ${file} has incorrect platform or version metadata`);
45
+ }
46
+ }
47
+ rmSync(destination, { recursive: true, force: true });
48
+ mkdirSync(path.dirname(destination), { recursive: true });
49
+ renameSync(sdk, destination);
50
+ } finally {
51
+ rmSync(temporary, { recursive: true, force: true });
52
+ }
53
+ return target;
54
+ }
@@ -0,0 +1,2 @@
1
+ export const CORE_VERSION = "0.1.1";
2
+ export const MIN_CLI_VERSION = "0.1.2";
@@ -0,0 +1,32 @@
1
+ BasedOnStyle: Google
2
+ Standard: c++20
3
+ ColumnLimit: 120
4
+ IndentWidth: 4
5
+ TabWidth: 4
6
+ ContinuationIndentWidth: 4
7
+ ConstructorInitializerIndentWidth: 4
8
+ AccessModifierOffset: -4
9
+ NamespaceIndentation: All
10
+ UseTab: Never
11
+ PointerAlignment: Left
12
+ DerivePointerAlignment: false
13
+ AllowShortFunctionsOnASingleLine: Inline
14
+ AllowShortIfStatementsOnASingleLine: WithoutElse
15
+ AllowShortLoopsOnASingleLine: false
16
+ RemoveBracesLLVM: true
17
+ SortIncludes: Never
18
+ FixNamespaceComments: false
19
+ AlignAfterOpenBracket: BlockIndent
20
+ AlignOperands: DontAlign
21
+ AlignTrailingComments: false
22
+ AllowAllArgumentsOnNextLine: false
23
+ AllowAllParametersOfDeclarationOnNextLine: false
24
+ BinPackArguments: false
25
+ BinPackParameters: false
26
+ BreakBeforeBinaryOperators: NonAssignment
27
+ BreakConstructorInitializers: BeforeColon
28
+ PackConstructorInitializers: Never
29
+ LambdaBodyIndentation: OuterScope
30
+ PenaltyBreakBeforeFirstCallParameter: 10
31
+ AlwaysBreakAfterReturnType: None
32
+ PenaltyReturnTypeOnItsOwnLine: 1000
@@ -0,0 +1,135 @@
1
+ export type Payload = Record<string, unknown> | unknown[] | string | number | boolean | null;
2
+
3
+ export interface InvokeOptions {
4
+ id?: string;
5
+ window?: string;
6
+ webview?: string;
7
+ }
8
+
9
+ export interface ListenOptions {
10
+ window?: string;
11
+ }
12
+
13
+ export interface IpcErrorShape {
14
+ code: string;
15
+ message: string;
16
+ details?: Payload;
17
+ }
18
+
19
+ export interface IpcFailure extends Error {
20
+ code: string;
21
+ details?: Payload;
22
+ }
23
+
24
+ export interface ChannelMessage {
25
+ sequence: number;
26
+ payload: Payload;
27
+ state: 'open' | 'closed';
28
+ }
29
+
30
+ export interface Channel {
31
+ identifier: number;
32
+ name: string;
33
+ send(payload?: Payload): Promise<void>;
34
+ deliver(message: ChannelMessage): void;
35
+ close(): void;
36
+ }
37
+
38
+ export interface Metadata {
39
+ readonly protocol: string;
40
+ readonly host: string;
41
+ readonly version: string;
42
+ }
43
+
44
+ export function isFlare(): boolean;
45
+ export function metadata(): Metadata;
46
+ export function invoke<T = unknown>(command: string, payload?: Payload, options?: InvokeOptions): Promise<T>;
47
+ export function listen(event: string, handler: (payload: Payload) => void, options?: ListenOptions): Promise<() => Promise<void>>;
48
+ export function once(event: string, handler: (payload: Payload) => void, options?: ListenOptions): Promise<() => Promise<void>>;
49
+ export function unlisten(event: string, id: number): Promise<void>;
50
+ export function emit(event: string, payload?: Payload): Promise<void>;
51
+ export function dispatch(event: string, payload?: Payload): number;
52
+ export function createChannel(onMessage: (message: ChannelMessage) => void): Channel;
53
+ export function currentWindow(): {
54
+ label: string;
55
+ close(): Promise<unknown>;
56
+ setTitle(title: string): Promise<unknown>;
57
+ show(): Promise<unknown>;
58
+ hide(): Promise<unknown>;
59
+ };
60
+
61
+ export const app: {
62
+ version(): Promise<string>;
63
+ describe(): Promise<Payload>;
64
+ config(): Promise<Payload>;
65
+ platform(): Promise<Payload>;
66
+ exit(): Promise<unknown>;
67
+ };
68
+
69
+ export const window: {
70
+ list(): Promise<string[]>;
71
+ emit(event: string, payload?: Payload): Promise<unknown>;
72
+ };
73
+
74
+ export const plugins: { list(): Promise<Payload> };
75
+ export interface MenuItem {
76
+ id?: string;
77
+ kind?: 'text' | 'check' | 'radio' | 'separator' | 'submenu' | 'predefined';
78
+ text?: string;
79
+ accelerator?: string;
80
+ enabled?: boolean;
81
+ checked?: boolean;
82
+ icon?: string;
83
+ items?: MenuItem[];
84
+ }
85
+
86
+ export const menu: {
87
+ describe(): Promise<{ id: string; items: MenuItem[] }>;
88
+ set(items: MenuItem[]): Promise<boolean>;
89
+ dispatch(id: string): Promise<boolean>;
90
+ onCommand(handler: (id: string, payload: { id: string }) => void): () => Promise<boolean>;
91
+ };
92
+ export const commands: {
93
+ register(name: string, handler: (payload: Payload) => Payload): void;
94
+ unregister(name: string): boolean;
95
+ has(name: string): boolean;
96
+ names(): string[];
97
+ };
98
+ export const log: {
99
+ trace(message: string): Promise<void>;
100
+ debug(message: string): Promise<void>;
101
+ info(message: string): Promise<void>;
102
+ warn(message: string): Promise<void>;
103
+ error(message: string): Promise<void>;
104
+ };
105
+
106
+ export type DirectoryName =
107
+ | 'appData'
108
+ | 'appLocalData'
109
+ | 'appConfig'
110
+ | 'appCache'
111
+ | 'appLog'
112
+ | 'home'
113
+ | 'documents'
114
+ | 'downloads'
115
+ | 'desktop'
116
+ | 'pictures'
117
+ | 'temp'
118
+ | 'executable'
119
+ | 'resource'
120
+ | 'current';
121
+
122
+ export const path: {
123
+ resolve(name: DirectoryName | string): Promise<{ name: string; path: string }>;
124
+ all(): Promise<Record<DirectoryName, string>>;
125
+ };
126
+
127
+ export const tray: {
128
+ describe(): Promise<Payload & { menu: { id: string; items: MenuItem[] } }>;
129
+ setIcon(icon: string): Promise<Payload>;
130
+ setTooltip(tooltip: string): Promise<Payload>;
131
+ setTitle(title: string): Promise<Payload>;
132
+ setVisible(visible: boolean): Promise<Payload>;
133
+ setMenu(items: MenuItem[]): Promise<Payload>;
134
+ dispatch(id: string): Promise<boolean>;
135
+ };
@@ -0,0 +1,278 @@
1
+ const protocol = 'flare';
2
+ const defaultHost = 'localhost';
3
+
4
+ const coreCommands = new Map();
5
+ const eventListeners = new Map();
6
+ const channels = new Map();
7
+
8
+ let listenerSequence = 0;
9
+ let channelSequence = 0;
10
+
11
+ function resolveBridge() {
12
+ const bridge = globalThis.__FLARE_BRIDGE__;
13
+ if (!bridge) {
14
+ throw new Error('flare: the ipc bridge is not available in this context');
15
+ }
16
+ return bridge;
17
+ }
18
+
19
+ function normalizeCallback(callback) {
20
+ if (callback === undefined || callback === null) {
21
+ return undefined;
22
+ }
23
+ if (typeof callback === 'function') {
24
+ return callback;
25
+ }
26
+ throw new TypeError('flare: callback must be a function');
27
+ }
28
+
29
+ export function isFlare() {
30
+ return typeof globalThis.__FLARE_BRIDGE__ !== 'undefined';
31
+ }
32
+
33
+ export function metadata() {
34
+ const bridge = globalThis.__FLARE_BRIDGE__;
35
+ return Object.freeze({
36
+ protocol,
37
+ host: (bridge && bridge.host) || defaultHost,
38
+ version: (bridge && bridge.version) || '0.0.0'
39
+ });
40
+ }
41
+
42
+ export async function invoke(command, payload = {}, options = {}) {
43
+ if (typeof command !== 'string' || command.length === 0) {
44
+ throw new TypeError('flare: command must be a non empty string');
45
+ }
46
+ const bridge = resolveBridge();
47
+ const request = {
48
+ id: options.id || `${Date.now().toString(36)}-${(listenerSequence += 1).toString(36)}`,
49
+ command,
50
+ payload: payload === undefined ? {} : payload,
51
+ window: options.window || '',
52
+ webview: options.webview || ''
53
+ };
54
+ const response = await bridge.invoke(request);
55
+ if (response === undefined || response === null) {
56
+ throw new Error(`flare: command '${command}' returned no response`);
57
+ }
58
+ if (response.successful === false) {
59
+ const error = response.error || {};
60
+ const failure = new Error(error.message || `flare: command '${command}' failed`);
61
+ failure.code = error.code || 'command-failed';
62
+ failure.details = error.details;
63
+ throw failure;
64
+ }
65
+ return response.result;
66
+ }
67
+
68
+ export async function listen(event, handler, options = {}) {
69
+ if (typeof event !== 'string' || event.length === 0) {
70
+ throw new TypeError('flare: event name must be a non empty string');
71
+ }
72
+ const callback = normalizeCallback(handler);
73
+ listenerSequence += 1;
74
+ const id = listenerSequence;
75
+ const window = options.window || '';
76
+ if (!eventListeners.has(event)) {
77
+ eventListeners.set(event, new Map());
78
+ }
79
+ eventListeners.get(event).set(id, { callback, window, once: false });
80
+ const bridge = resolveBridge();
81
+ if (typeof bridge.attachListener === 'function') {
82
+ bridge.attachListener(event, id, callback);
83
+ }
84
+ try {
85
+ await bridge.listen({ event, id, window });
86
+ } catch (error) {
87
+ eventListeners.get(event)?.delete(id);
88
+ await bridge.unlisten?.({ event, id });
89
+ throw error;
90
+ }
91
+ return () => unlisten(event, id);
92
+ }
93
+
94
+ export async function once(event, handler, options = {}) {
95
+ const remove = await listen(
96
+ event,
97
+ (payload) => {
98
+ remove();
99
+ if (typeof handler === 'function') {
100
+ handler(payload);
101
+ }
102
+ },
103
+ options
104
+ );
105
+ return remove;
106
+ }
107
+
108
+ export async function unlisten(event, id) {
109
+ const entries = eventListeners.get(event);
110
+ if (entries) {
111
+ entries.delete(id);
112
+ if (entries.size === 0) {
113
+ eventListeners.delete(event);
114
+ }
115
+ }
116
+ const bridge = globalThis.__FLARE_BRIDGE__;
117
+ if (bridge && typeof bridge.unlisten === 'function') {
118
+ await bridge.unlisten({ event, id });
119
+ }
120
+ }
121
+
122
+ export async function emit(event, payload = {}) {
123
+ const bridge = resolveBridge();
124
+ await bridge.emit({ event, payload });
125
+ }
126
+
127
+ export function dispatch(event, payload = {}) {
128
+ const entries = eventListeners.get(event);
129
+ if (!entries) {
130
+ return 0;
131
+ }
132
+ const pending = [];
133
+ for (const entry of entries.values()) {
134
+ pending.push(entry);
135
+ }
136
+ for (const entry of pending) {
137
+ if (entry.once) {
138
+ entries.delete(entry);
139
+ }
140
+ try {
141
+ entry.callback(payload);
142
+ } catch (error) {
143
+ console.error(`flare: listener for '${event}' failed`, error);
144
+ }
145
+ }
146
+ return pending.length;
147
+ }
148
+
149
+ export function createChannel(onMessage) {
150
+ channelSequence += 1;
151
+ const id = channelSequence;
152
+ const name = `channel-${id}`;
153
+ channels.set(id, { onMessage, closed: false });
154
+ return {
155
+ identifier: id,
156
+ name,
157
+ async send(payload = {}) {
158
+ const bridge = resolveBridge();
159
+ await bridge.channel({ id, payload });
160
+ },
161
+ deliver(message) {
162
+ if (onMessage) {
163
+ onMessage(message);
164
+ }
165
+ if (message && message.state === 'closed') {
166
+ channels.delete(id);
167
+ }
168
+ },
169
+ close() {
170
+ channels.delete(id);
171
+ }
172
+ };
173
+ }
174
+
175
+ export function currentWindow() {
176
+ const label = (globalThis.__FLARE_BRIDGE__ && globalThis.__FLARE_BRIDGE__.window) || 'main';
177
+ return {
178
+ label,
179
+ async close() {
180
+ return invoke('flare:window:close', { label });
181
+ },
182
+ async setTitle(title) {
183
+ return invoke('flare:window:set-title', { label, title });
184
+ },
185
+ async show() {
186
+ return invoke('flare:window:show', { label });
187
+ },
188
+ async hide() {
189
+ return invoke('flare:window:hide', { label });
190
+ }
191
+ };
192
+ }
193
+
194
+ export const app = Object.freeze({
195
+ version: () => invoke('flare:app:version'),
196
+ describe: () => invoke('flare:app:describe'),
197
+ config: () => invoke('flare:app:config'),
198
+ platform: () => invoke('flare:app:platform'),
199
+ exit: () => invoke('flare:app:exit')
200
+ });
201
+
202
+ export const window = Object.freeze({
203
+ list: () => invoke('flare:window:list'),
204
+ emit: (event, payload = {}) => invoke('flare:window:emit', { event, payload })
205
+ });
206
+
207
+ export const plugins = Object.freeze({
208
+ list: () => invoke('flare:plugin:list')
209
+ });
210
+
211
+ export const menu = Object.freeze({
212
+ describe: () => invoke('flare:menu:describe'),
213
+ set: (items) => invoke('flare:menu:set', { items }),
214
+ dispatch: (id) => invoke('flare:menu:dispatch', { id }),
215
+ onCommand: (handler) => listen('flare:menu:command', (payload) => handler(payload.id, payload))
216
+ });
217
+
218
+ export const commands = Object.freeze({
219
+ register(name, handler) {
220
+ if (typeof name !== 'string' || typeof handler !== 'function') {
221
+ throw new TypeError('flare: register expects a name and a handler');
222
+ }
223
+ coreCommands.set(name, handler);
224
+ },
225
+ unregister(name) {
226
+ return coreCommands.delete(name);
227
+ },
228
+ has(name) {
229
+ return coreCommands.has(name);
230
+ },
231
+ names() {
232
+ return Array.from(coreCommands.keys());
233
+ }
234
+ });
235
+
236
+ export const log = Object.freeze({
237
+ trace: (message) => emit('flare:log:trace', { message }),
238
+ debug: (message) => emit('flare:log:debug', { message }),
239
+ info: (message) => emit('flare:log:info', { message }),
240
+ warn: (message) => emit('flare:log:warn', { message }),
241
+ error: (message) => emit('flare:log:error', { message })
242
+ });
243
+
244
+ export const path = Object.freeze({
245
+ resolve: (name) => invoke('flare:path:resolve', { name }),
246
+ all: () => invoke('flare:path:all')
247
+ });
248
+
249
+ export const tray = Object.freeze({
250
+ describe: () => invoke('flare:tray:describe'),
251
+ setIcon: (icon) => invoke('flare:tray:set-icon', { icon }),
252
+ setTooltip: (tooltip) => invoke('flare:tray:set-tooltip', { tooltip }),
253
+ setTitle: (title) => invoke('flare:tray:set-title', { title }),
254
+ setVisible: (visible) => invoke('flare:tray:set-visible', { visible }),
255
+ setMenu: (items) => invoke('flare:tray:set-menu', { items }),
256
+ dispatch: (id) => invoke('flare:tray:dispatch', { id })
257
+ });
258
+
259
+ export default {
260
+ app,
261
+ commands,
262
+ createChannel,
263
+ currentWindow,
264
+ dispatch,
265
+ emit,
266
+ invoke,
267
+ isFlare,
268
+ listen,
269
+ log,
270
+ menu,
271
+ metadata,
272
+ once,
273
+ path,
274
+ plugins,
275
+ tray,
276
+ unlisten,
277
+ window
278
+ };
@@ -7,7 +7,7 @@ export function embeddedIdentifier(name) {
7
7
  .join("");
8
8
  }
9
9
 
10
- export function configTemplate({ name, identifier, version }) {
10
+ export function configTemplate({ name, identifier, version, template = "javascript" }) {
11
11
  return {
12
12
  $schema: SCHEMA_URL,
13
13
  package: { productName: name, version },
@@ -18,11 +18,11 @@ export function configTemplate({ name, identifier, version }) {
18
18
  version
19
19
  },
20
20
  build: {
21
- frontendDist: "frontend",
22
- devUrl: "",
23
- beforeDevCommand: "",
21
+ frontendDist: template === "typescript" ? "dist" : "frontend",
22
+ devUrl: template === "typescript" ? "http://127.0.0.1:5173" : "",
23
+ beforeDevCommand: template === "typescript" ? "npm run frontend:dev" : "",
24
24
  afterDevCommand: "",
25
- beforeBuildCommand: "",
25
+ beforeBuildCommand: template === "typescript" ? "npm run frontend:build" : "",
26
26
  afterBuildCommand: ""
27
27
  },
28
28
  bundle: {
@@ -51,7 +51,7 @@ export function configTemplate({ name, identifier, version }) {
51
51
  };
52
52
  }
53
53
 
54
- export function packageJsonTemplate({ name, version, cliVersion }) {
54
+ export function packageJsonTemplate({ name, version, cliVersion, template = "javascript" }) {
55
55
  return {
56
56
  name,
57
57
  version,
@@ -61,15 +61,17 @@ export function packageJsonTemplate({ name, version, cliVersion }) {
61
61
  dev: "flare dev",
62
62
  build: "flare build",
63
63
  bundle: "flare bundle",
64
- icon: "flare icon icons/icon.png"
64
+ icon: "flare icon icons/icon.png",
65
+ ...(template === "typescript" ? { "frontend:dev": "vite --host 127.0.0.1", "frontend:build": "tsc --noEmit && vite build" } : {})
65
66
  },
66
67
  devDependencies: {
67
- "@flare-desktop/cli": `^${cliVersion}`
68
+ "@flare-desktop/cli": `^${cliVersion}`,
69
+ ...(template === "typescript" ? { typescript: "^5.9.3", vite: "^6.4.5" } : {})
68
70
  }
69
71
  };
70
72
  }
71
73
 
72
- export function cmakeTemplate({ name, version, coreVersion }) {
74
+ export function cmakeTemplate({ name, version, coreVersion, sdkMode = "source" }) {
73
75
  return `cmake_minimum_required(VERSION 3.24)
74
76
 
75
77
  project(${name} VERSION ${version} LANGUAGES CXX)
@@ -77,18 +79,46 @@ project(${name} VERSION ${version} LANGUAGES CXX)
77
79
  set(CMAKE_CXX_STANDARD 20)
78
80
  set(CMAKE_CXX_STANDARD_REQUIRED ON)
79
81
 
80
- include(FetchContent)
82
+ if(WIN32 AND NOT DEFINED FLARE_NATIVE_WEBVIEW)
83
+ set(FLARE_NATIVE_WEBVIEW ON CACHE BOOL "Use the Windows system webview")
84
+ endif()
85
+
86
+ ${sdkMode === "prebuilt" ? `add_subdirectory("\${CMAKE_CURRENT_SOURCE_DIR}/vendor/flare" "\${CMAKE_CURRENT_BINARY_DIR}/flare-sdk")` : `include(FetchContent)
81
87
 
82
88
  FetchContent_Declare(
83
89
  flare
84
90
  GIT_REPOSITORY https://github.com/flare-desktop/flare.git
85
91
  GIT_TAG v${coreVersion}
86
92
  GIT_SHALLOW TRUE)
87
- FetchContent_MakeAvailable(flare)
93
+ FetchContent_MakeAvailable(flare)`}
88
94
 
89
95
  add_executable(${name} source/main.cpp)
90
96
  target_link_libraries(${name} PRIVATE flare::core flare::assets)
91
97
 
98
+ find_program(FLARE_NODE_EXECUTABLE node REQUIRED)
99
+ set(FLARE_CONFIG_FILE "flare.config.json" CACHE STRING "Source application configuration")
100
+ set(FLARE_CONFIG_ENVIRONMENT "production" CACHE STRING "Build configuration layer")
101
+ get_filename_component(FLARE_CONFIG_ABSOLUTE "\${FLARE_CONFIG_FILE}" ABSOLUTE BASE_DIR "\${CMAKE_CURRENT_SOURCE_DIR}")
102
+ get_filename_component(FLARE_CONFIG_DIRECTORY "\${FLARE_CONFIG_ABSOLUTE}" DIRECTORY)
103
+ get_filename_component(FLARE_CONFIG_NAME "\${FLARE_CONFIG_ABSOLUTE}" NAME_WE)
104
+ file(GLOB FLARE_CONFIG_LAYERS CONFIGURE_DEPENDS "\${FLARE_CONFIG_DIRECTORY}/\${FLARE_CONFIG_NAME}*.json")
105
+ set(FLARE_CONFIG_GENERATOR "\${CMAKE_CURRENT_SOURCE_DIR}/node_modules/@flare-desktop/cli/src/lib/embed-config-cli.js")
106
+ set(FLARE_CONFIG_IMPLEMENTATION "\${CMAKE_CURRENT_SOURCE_DIR}/node_modules/@flare-desktop/cli/src/lib/embedded-config.js")
107
+ set(FLARE_CONFIG_LOADER "\${CMAKE_CURRENT_SOURCE_DIR}/node_modules/@flare-desktop/cli/src/lib/project.js")
108
+ set(FLARE_GENERATED_CONFIG "\${CMAKE_CURRENT_BINARY_DIR}/flare-embedded-config.h")
109
+ file(GENERATE OUTPUT "\${CMAKE_CURRENT_BINARY_DIR}/flare-config-selection.txt"
110
+ CONTENT "\${FLARE_CONFIG_FILE}\n\${FLARE_CONFIG_ENVIRONMENT}\n")
111
+ add_custom_command(
112
+ OUTPUT "\${FLARE_GENERATED_CONFIG}"
113
+ COMMAND "\${FLARE_NODE_EXECUTABLE}" "\${FLARE_CONFIG_GENERATOR}"
114
+ "\${CMAKE_CURRENT_SOURCE_DIR}" "\${FLARE_CONFIG_FILE}" "\${FLARE_CONFIG_ENVIRONMENT}"
115
+ "\${FLARE_GENERATED_CONFIG}"
116
+ DEPENDS "\${FLARE_CONFIG_GENERATOR}" "\${FLARE_CONFIG_IMPLEMENTATION}" "\${FLARE_CONFIG_LOADER}"
117
+ "\${CMAKE_CURRENT_BINARY_DIR}/flare-config-selection.txt" \${FLARE_CONFIG_LAYERS}
118
+ VERBATIM)
119
+ target_sources(${name} PRIVATE "\${FLARE_GENERATED_CONFIG}")
120
+ target_include_directories(${name} PRIVATE "\${CMAKE_CURRENT_BINARY_DIR}")
121
+
92
122
  set(FLARE_FRONTEND_DIR "frontend" CACHE PATH "Frontend directory")
93
123
  file(TO_CMAKE_PATH "\${FLARE_FRONTEND_DIR}" FLARE_FRONTEND_DIR)
94
124
  target_compile_definitions(${name} PRIVATE FLARE_FRONTEND_DIR="\${FLARE_FRONTEND_DIR}")
@@ -96,13 +126,14 @@ FlareEmbedFrontend(${name} "\${FLARE_FRONTEND_DIR}")
96
126
  `;
97
127
  }
98
128
 
99
- export function sourceTemplate({ name, identifier, version }) {
129
+ export function sourceTemplate({ name }) {
100
130
  const embedded = embeddedIdentifier(name);
101
131
  return `#include <flare/config/config.h>
102
132
  #include <flare/core/application.h>
103
133
  #include <flare/log/log.h>
104
134
 
105
135
  #include <${name}-frontend.h>
136
+ #include <flare-embedded-config.h>
106
137
 
107
138
  #include <cstdlib>
108
139
  #include <string>
@@ -115,20 +146,9 @@ int main() {
115
146
  const bool development = std::getenv("FLARE_DEV") != nullptr;
116
147
 
117
148
  Flare::ApplicationOptions options;
118
- options.config = Flare::Config::Defaults("${identifier}", "${version}");
119
- options.config.app.productName = "${name}";
120
- options.config.windows.front().title = "${name}";
149
+ options.config = FlareGenerated::EmbeddedConfig();
121
150
  options.development = development;
122
151
 
123
- std::string error;
124
- Flare::ConfigLoader loader(
125
- Flare::ConfigMergeOptions{"flare.config.json", development ? "development" : "production", "", {}});
126
- if (const auto loaded = loader.Load(error); loaded.has_value()) {
127
- options.config = *loaded;
128
- } else {
129
- Flare::LogWarn("using the built-in configuration: {}", error);
130
- }
131
-
132
152
  const char* devUrl = std::getenv("FLARE_DEV_URL");
133
153
  if (devUrl != nullptr) options.devUrl = devUrl;
134
154
  if (development && options.devUrl.empty()) options.frontendDirectory = FLARE_FRONTEND_DIR;
@@ -150,7 +170,7 @@ int main() {
150
170
  `;
151
171
  }
152
172
 
153
- export function indexHtmlTemplate(name) {
173
+ export function indexHtmlTemplate(name, template = "javascript") {
154
174
  return `<!doctype html>
155
175
  <html lang="en">
156
176
  <head>
@@ -166,7 +186,7 @@ export function indexHtmlTemplate(name) {
166
186
  <button id="greet">call the backend</button>
167
187
  <pre id="output"></pre>
168
188
  </main>
169
- <script type="module" src="main.js"></script>
189
+ <script type="module" src="${template === "typescript" ? "main.ts" : "main.js"}"></script>
170
190
  </body>
171
191
  </html>
172
192
  `;
@@ -246,8 +266,44 @@ document.querySelector("#greet").addEventListener("click", async () => {
246
266
  `;
247
267
  }
248
268
 
269
+ export function typescriptTemplate() {
270
+ return `import { app, invoke } from "/core.js";
271
+
272
+ const output = document.querySelector<HTMLPreElement>("#output");
273
+ const button = document.querySelector<HTMLButtonElement>("#greet");
274
+
275
+ button?.addEventListener("click", async () => {
276
+ const message = await invoke<string>("greet", { name: "flare" });
277
+ const version = await app.version();
278
+ if (output) output.textContent = \`\${message} (\${version})\`;
279
+ });
280
+ `;
281
+ }
282
+
283
+ export function tsconfigTemplate() {
284
+ return {
285
+ compilerOptions: {
286
+ target: "ES2022", lib: ["ES2022", "DOM"], module: "ESNext", moduleResolution: "Bundler",
287
+ strict: true, noEmit: true, skipLibCheck: true, types: ["vite/client"],
288
+ paths: { "/core.js": ["./frontend/core.d.ts"] }
289
+ },
290
+ include: ["frontend/main.ts"]
291
+ };
292
+ }
293
+
294
+ export function viteConfigTemplate() {
295
+ return `import { defineConfig } from "vite";
296
+ export default defineConfig({
297
+ root: "frontend",
298
+ base: "./",
299
+ build: { outDir: "../dist", emptyOutDir: true },
300
+ server: { host: "127.0.0.1", port: 5173, strictPort: true }
301
+ });
302
+ `;
303
+ }
304
+
249
305
  export function gitignoreTemplate() {
250
- return "build/\ntarget/\nnode_modules/\n.DS_Store\n";
306
+ return "build/\ntarget/\nnode_modules/\ndist/\n.DS_Store\n";
251
307
  }
252
308
 
253
309
  export function readmeTemplate(name) {
@@ -270,7 +326,7 @@ npm run bundle
270
326
  \`\`\`
271
327
 
272
328
  \`npm run dev\` builds the C++ core and serves \`frontend/\` in the system webview.
273
- \`npm run build\` compiles the frontend into the executable and produces a release build.
329
+ \`npm run build\` compiles the frontend and the effective typed configuration into the executable. No JSON file is needed at runtime. On Windows, the native webview is enabled automatically. A configuration change requires rebuilding.
274
330
  \`npm run bundle\` assembles the configured bundle targets under \`target/bundle\`.
275
331
 
276
332
  ## Icons