From 6fffb1403681e92e503e79cbc8c0322b067a840f Mon Sep 17 00:00:00 2001 From: twlite <46562212+twlite@users.noreply.github.com> Date: Thu, 3 Oct 2024 12:16:22 +0545 Subject: [PATCH] feat: add cli --- README.md | 11 +++- __test__/webview.test.mjs | 6 ++ cli/build.mjs | 104 +++++++++++++++++++++++++++++++ cli/index.mjs | 110 +++++++++++++++++++++++++++++++++ cli/utils.mjs | 7 +++ examples/{html.mjs => html.js} | 5 +- package.json | 7 ++- yarn.lock | 2 + 8 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 __test__/webview.test.mjs create mode 100644 cli/build.mjs create mode 100644 cli/index.mjs create mode 100644 cli/utils.mjs rename examples/{html.mjs => html.js} (65%) diff --git a/README.md b/README.md index c890865..4c14f0a 100644 --- a/README.md +++ b/README.md @@ -50,4 +50,13 @@ Check out [examples](./examples) directory for more examples. # Building executables -You can use [Single Executable Applications](https://nodejs.org/api/single-executable-applications.html) feature of Node.js to build an executable file. +> [!WARNING] +> The CLI feature is very experimental and may not work as expected. Please report any issues you find. + +You can use [Single Executable Applications](https://nodejs.org/api/single-executable-applications.html) feature of Node.js to build an executable file. WebviewJS comes with a helper cli script to make this process easier. + +```bash +webview --build --input ./path/to/your/script.js --output ./path/to/output-directory --name my-app +``` + +You can pass `--resources ./my-resource.json` to include additional resources in the executable. This resource can be imported using `getAsset()` or `getRawAsset()` functions from `node:sea` module. diff --git a/__test__/webview.test.mjs b/__test__/webview.test.mjs new file mode 100644 index 0000000..6ddff5e --- /dev/null +++ b/__test__/webview.test.mjs @@ -0,0 +1,6 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +test('webview', () => { + assert.equal(1, 1) +}) diff --git a/cli/build.mjs b/cli/build.mjs new file mode 100644 index 0000000..2e9a011 --- /dev/null +++ b/cli/build.mjs @@ -0,0 +1,104 @@ +import { execSync } from 'node:child_process'; +import { copyFileSync, constants, writeFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, join, sep } from 'node:path'; +import { execPath } from 'node:process' + +const isWindows = process.platform === 'win32'; +const isMac = process.platform === 'darwin'; +const NODE_SEA_FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; + +const filename = (path) => path.split(sep).pop(); + +function writeSeaConfig(main, dest, resources) { + const config = { + main, + output: join(dirname(dest), 'sea-prep.blob'), + disableExperimentalSEAWarning: true, + assets: resources + }; + + writeFileSync(dest, JSON.stringify(config, null, 2)); +} + +function run(command, args) { + const cmd = !args?.length ? command : `${command} ${args.join(' ')}`; + execSync(cmd, { stdio: 'inherit' }); +} + +function generateBlob(configPath) { + run('node', ['--experimental-sea-config', configPath]) + + return join(dirname(configPath), 'sea-prep.blob'); +} + +function copyNode(output, name) { + const ext = isWindows ? '.exe' : ''; + const f = join(output, name + ext); + copyFileSync(execPath, f, constants.COPYFILE_FICLONE); + + return f; +} + +function removeSignature(path) { + if (!isWindows && !isMac) return; + + if (isWindows) { + try { + run('signtool remove /s ' + path) + } catch (e) { + console.warn(`Failed to remove signature: ${e.message}`) + } + } else { + run('codesign --remove-signature ' + path) + } +} + +function injectFuse(target, blob) { + let args; + + if (isMac) { + args = [`"${target}"`, 'NODE_SEA_BLOB', blob, '--sentinel-fuse', NODE_SEA_FUSE, '--macho-segment-name', 'NODE_SEA']; + } else { + args = [target, 'NODE_SEA_BLOB', blob, '--sentinel-fuse', NODE_SEA_FUSE]; + } + + run('npx', ['--yes', 'postject', ...args]) +} + +function sign(bin) { + if (isWindows) { + try { + run('signtool', ['sign', '/fd', 'SHA256', bin]) + } catch (e) { + console.warn(`Failed to sign: ${e.message}`) + } + } else if (isMac) { + run('codesign', ['--sign', '-', bin]) + } +} + +export function build(input, output, name, resources) { + if (!existsSync(input)) { + throw new Error('Input file does not exist'); + } + + if (resources && !existsSync(resources)) { + throw new Error('Resources file does not exist'); + } + + if (!existsSync(output)) { + mkdirSync(output, { recursive: true }); + } + + const assets = JSON.parse(readFileSync(resources, 'utf-8')); + const configPath = join(output, 'sea-config.json'); + const execPath = copyNode(output, name); + + writeSeaConfig(input, configPath, assets); + const blob = generateBlob(configPath); + removeSignature(execPath); + injectFuse(execPath, blob); + sign(execPath); + + return execPath; +} \ No newline at end of file diff --git a/cli/index.mjs b/cli/index.mjs new file mode 100644 index 0000000..00bb2ce --- /dev/null +++ b/cli/index.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises' +import { parseArgs, styleText } from 'node:util' +import { join } from 'node:path'; +import { stripIndents } from './utils.mjs'; +import { build } from './build.mjs'; + +async function readPackageJSON() { + const packageJSON = await readFile(join(import.meta.dirname, '..', 'package.json'), 'utf-8'); + return JSON.parse(packageJSON); +} + +const { version, description, main } = await readPackageJSON(); + +const options = { + help: { type: 'boolean', short: 'h', description: 'Show help' }, + version: { type: 'boolean', short: 'v', description: 'Show version' }, + build: { + type: 'boolean', + short: 'b', + description: 'Build the project', + }, + name: { + type: 'string', + short: 'n', + default: 'webviewjs', + description: 'Project name', + }, + output: { + type: 'string', + short: 'o', + default: join(process.cwd(), 'dist'), + description: 'Output directory', + }, + input: { + type: 'string', + short: 'i', + default: join(process.cwd(), main), + description: 'Entry file', + }, + resources: { + type: 'string', + short: 'r', + description: 'Resources mapping json file path' + }, + 'dry-run': { + type: 'boolean', + short: 'd', + description: 'Dry run', + } +}; + +const args = parseArgs({ + strict: true, + args: process.argv.slice(2), + options +}); + +let stdErr = false; + + +const logger = (message) => { + console.log(message); + process.exit(+stdErr); +} + +const defaultValuesOptionNames = new Set(Object.keys(options).filter(k => !!options[k].default)); + +if (!Object.keys(args.values).filter(k => !defaultValuesOptionNames.has(k)).length) { + args.values.help = true; + stdErr = true; +} + +if (args.values.help) { + const message = stripIndents`WebviewJS: ${styleText('greenBright', description)} + + ${styleText('dim', 'Usage:')} ${styleText('greenBright', 'webview [options]')} + + ${styleText('dim', 'Options:')} +${Object.entries(options).map(([name, { short, default: defaultValue, type }]) => { + const msg = ` ${styleText('greenBright', ` -${short}, --${name}`)} - ${styleText('dim', options[name].description || `${type} option`)}`; + + if (defaultValue) { + return `${msg} (default: ${styleText('blueBright', defaultValue)})`; + } + + return msg; + }).join('\n')} + `; + + logger(message); +} else if (args.values.version) { + logger(`- WebviewJS v${version}\n- Node.js ${process.version}\n- Operating System: ${process.platform} ${process.arch}`); +} else if (args.values.build) { + const isDry = !!args.values['dry-run']; + const { output, input, resources } = args.values; + + if (isDry) { + logger(`Dry run: building ${input} to ${output}`); + } else { + const projectName = args.values.name || 'webviewjs'; + const target = build(input, output, prettify(projectName), resources); + logger(styleText('greenBright', `\nBuilt ${input} to ${target}. You can now run the executable using ${styleText(['cyanBright', 'bold'], target)}`)); + } +} + +function prettify(str) { + // remove stuff like @, /, whitespace, etc + return str.replace(/[^a-zA-Z0-9]/g, ''); +} \ No newline at end of file diff --git a/cli/utils.mjs b/cli/utils.mjs new file mode 100644 index 0000000..e0a075e --- /dev/null +++ b/cli/utils.mjs @@ -0,0 +1,7 @@ +export function stripIndents(strings, ...values) { + let str = ''; + strings.forEach((string, i) => { + str += string + (values[i] || ''); + }); + return str.replace(/(\t)+/g, ' ').trim(); +} \ No newline at end of file diff --git a/examples/html.mjs b/examples/html.js similarity index 65% rename from examples/html.mjs rename to examples/html.js index 1255976..c084c85 100644 --- a/examples/html.mjs +++ b/examples/html.js @@ -1,8 +1,9 @@ -import { Application } from '../index.js'; +const requireScript = require('node:module').createRequire(__filename); +const { Application } = requireScript('../index.js'); const app = new Application(); const window = app.createBrowserWindow({ - html: ` + html: ` Webview diff --git a/package.json b/package.json index 271be37..1191d9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,10 @@ { "name": "@webviewjs/webview", "version": "0.1.0", + "bin": { + "webview": "./cli/index.mjs", + "webviewjs": "./cli/index.mjs" + }, "description": "Robust cross-platform webview library for Node.js written in Rust", "main": "index.js", "repository": "git@github.com:twlite/webview.git", @@ -48,7 +52,8 @@ "format:rs": "cargo fmt", "lint": "oxlint .", "prepublishOnly": "napi prepublish -t npm", - "version": "napi version" + "version": "napi version", + "test": "node --test './__test__/**/*.test.*'" }, "devDependencies": { "@napi-rs/cli": "^2.18.4", diff --git a/yarn.lock b/yarn.lock index 799dbdf..69a7ade 100644 --- a/yarn.lock +++ b/yarn.lock @@ -482,6 +482,8 @@ __metadata: prettier: "npm:^3.3.3" tinybench: "npm:^2.8.0" typescript: "npm:^5.5.3" + bin: + webview: ./cli/index.mjs languageName: unknown linkType: soft