@nwutils/packager 0.1.0 → 0.2.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.
- package/README.md +72 -0
- package/package.json +9 -6
- package/src/appImage.js +266 -0
- package/src/index.js +7 -1
- package/src/request.js +132 -0
- package/src/util.js +86 -0
- package/types/appImage.d.ts +75 -0
- package/types/index.d.ts +6 -0
- package/types/request.d.ts +8 -0
- package/types/util.d.ts +28 -0
package/README.md
CHANGED
|
@@ -2,10 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@nwutils/packager/v/latest)
|
|
4
4
|
|
|
5
|
+
Package NW.js applications for Linux, MacOS and Windows.
|
|
6
|
+
|
|
7
|
+
Currently supported: packaging a Linux application as an [AppImage](https://appimage.org/).
|
|
8
|
+
|
|
5
9
|
## Getting Started
|
|
6
10
|
|
|
7
11
|
1. `npm i` to install third party dependencies
|
|
8
12
|
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
This composes with `@nwutils/builder`: run it against the `outDir` (and `app.name`)
|
|
16
|
+
that `@nwutils/builder` produced for `platform: "linux"`.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import build from "@nwutils/builder";
|
|
20
|
+
import { appImage } from "@nwutils/packager";
|
|
21
|
+
|
|
22
|
+
await build({
|
|
23
|
+
version: "0.115.0",
|
|
24
|
+
platform: "linux",
|
|
25
|
+
arch: "x64",
|
|
26
|
+
srcDir: "./src",
|
|
27
|
+
cacheDir: "./cache",
|
|
28
|
+
outDir: "./out",
|
|
29
|
+
app: {
|
|
30
|
+
name: "Demo",
|
|
31
|
+
icon: "icon.png",
|
|
32
|
+
categories: "Utility;",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const appImagePath = await appImage({
|
|
37
|
+
appDir: "./out",
|
|
38
|
+
appName: "Demo",
|
|
39
|
+
cacheDir: "./cache",
|
|
40
|
+
outDir: "./dist",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
console.log(`AppImage written to ${appImagePath}`);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The first time it runs for a given architecture, `appImage()` downloads
|
|
47
|
+
[`appimagetool`](https://github.com/AppImage/appimagetool) into `cacheDir` and
|
|
48
|
+
reuses it on subsequent calls. `appimagetool` is a native Linux binary, so
|
|
49
|
+
`appImage()` only runs on a Linux host, targeting the host's own architecture
|
|
50
|
+
by default.
|
|
51
|
+
|
|
52
|
+
`appimagetool` is invoked with `APPIMAGE_EXTRACT_AND_RUN=1`, so a FUSE mount is
|
|
53
|
+
never required - useful in containers, CI runners and other sandboxes where
|
|
54
|
+
FUSE is typically unavailable.
|
|
55
|
+
|
|
56
|
+
## API Reference
|
|
57
|
+
|
|
58
|
+
### `appImage(options)`
|
|
59
|
+
|
|
60
|
+
Options
|
|
61
|
+
|
|
62
|
+
| Name | Type | Description |
|
|
63
|
+
| --------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
64
|
+
| appDir | `string` | Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"` |
|
|
65
|
+
| appName | `string` | Name of the application. Must match the `app.name` value used to build `appDir` |
|
|
66
|
+
| icon | `string` | Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop` |
|
|
67
|
+
| arch | `"ia32" \| "x64" \| "arm64"` | Target architecture. Defaults to the host architecture |
|
|
68
|
+
| outDir | `string` | Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir` |
|
|
69
|
+
| cacheDir | `string` | Directory used to cache the downloaded `appimagetool` binary. Defaults to `"./cache"` |
|
|
70
|
+
| cache | `boolean` | If true, reuse a cached `appimagetool` binary. Otherwise redownload it. Defaults to `true` |
|
|
71
|
+
| appImageToolUrl | `string` | Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" release |
|
|
72
|
+
|
|
73
|
+
Resolves with the path to the resulting `.AppImage` file.
|
|
74
|
+
|
|
75
|
+
The application's desktop entry file (`<appDir>/<appName>.desktop`, as written
|
|
76
|
+
by `@nwutils/builder`) is copied into the AppImage with its `Exec` and `Icon`
|
|
77
|
+
keys rewritten to match the AppImage's own layout, and a `Categories` key
|
|
78
|
+
added (defaulting to `Utility;`) if one isn't already present, since
|
|
79
|
+
`appimagetool` refuses to build an AppImage without one.
|
|
80
|
+
|
|
9
81
|
## Contributing
|
|
10
82
|
|
|
11
83
|
### External contributor
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nwutils/packager",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Package NW.js applications for Linux, MacOS and Windows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"NW.js",
|
|
7
7
|
"node-webkit",
|
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
],
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"main": "./src/index.js",
|
|
29
|
+
"types": "types/index.d.ts",
|
|
29
30
|
"type": "module",
|
|
30
31
|
"files": [
|
|
31
32
|
"LICENSE",
|
|
32
|
-
"src"
|
|
33
|
+
"src",
|
|
34
|
+
"types"
|
|
33
35
|
],
|
|
34
36
|
"homepage": "https://github.com/nwutils/nw-builder",
|
|
35
37
|
"repository": {
|
|
@@ -37,10 +39,11 @@
|
|
|
37
39
|
"url": "git+https://github.com/nwutils/nw-builder.git"
|
|
38
40
|
},
|
|
39
41
|
"scripts": {
|
|
40
|
-
"lint": "eslint .",
|
|
41
|
-
"lint:fix": "eslint . --fix",
|
|
42
|
+
"lint": "eslint . ../../tests/specs/packager ../../tests/fixtures/packager",
|
|
43
|
+
"lint:fix": "eslint . ../../tests/specs/packager ../../tests/fixtures/packager --fix",
|
|
42
44
|
"type": "tsc",
|
|
43
|
-
"test": "node --test \"../../tests/specs/packager
|
|
45
|
+
"test": "node --test --test-concurrency=1 \"../../tests/specs/packager/appImage.test.js\" \"../../tests/specs/packager/request.test.js\" \"../../tests/specs/packager/util.test.js\"",
|
|
46
|
+
"test:real": "node --test \"../../tests/specs/packager/appImage.real.test.js\""
|
|
44
47
|
},
|
|
45
48
|
"dependencies": {},
|
|
46
49
|
"devDependencies": {}
|
package/src/appImage.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import child_process from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
|
|
7
|
+
import request from "./request.js";
|
|
8
|
+
import util from "./util.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default location `appimagetool` is downloaded from. The `continuous` tag
|
|
12
|
+
* is rebuilt on every push to the default branch and publishes one asset per
|
|
13
|
+
* architecture, eg. `appimagetool-x86_64.AppImage`.
|
|
14
|
+
* @type {string}
|
|
15
|
+
*/
|
|
16
|
+
const APPIMAGETOOL_URL =
|
|
17
|
+
"https://github.com/AppImage/appimagetool/releases/download/continuous";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} AppImageOptions
|
|
21
|
+
* @property {string} appDir Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"`.
|
|
22
|
+
* @property {string} appName Name of the application. Must match the `app.name` value used to build `appDir` - the executable and desktop entry are expected at `<appDir>/<appName>` and `<appDir>/<appName>.desktop`.
|
|
23
|
+
* @property {string} [icon] Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop`.
|
|
24
|
+
* @property {"ia32" | "x64" | "arm64"} [arch] Target architecture. Defaults to the host architecture. `appimagetool` runs as a native binary, so this must match the host unless the host has emulation (eg. QEMU/binfmt) configured for the target architecture.
|
|
25
|
+
* @property {string} [outDir] Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir`.
|
|
26
|
+
* @property {string} [cacheDir="./cache"] Directory used to cache the downloaded `appimagetool` binary.
|
|
27
|
+
* @property {boolean} [cache=true] If true, reuse a cached `appimagetool` binary. Otherwise redownload it.
|
|
28
|
+
* @property {string} [appImageToolUrl] Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Package a built NW.js Linux application as an AppImage.
|
|
33
|
+
*
|
|
34
|
+
* This composes with `@nwutils/builder`: run it against the `outDir` (and
|
|
35
|
+
* `app.name`) that `@nwutils/builder` produced for `platform: "linux"`.
|
|
36
|
+
* @async
|
|
37
|
+
* @function
|
|
38
|
+
* @param {AppImageOptions} options
|
|
39
|
+
* @returns {Promise<string>} Path to the resulting `.AppImage` file.
|
|
40
|
+
*/
|
|
41
|
+
async function appImage({
|
|
42
|
+
appDir,
|
|
43
|
+
appName,
|
|
44
|
+
icon,
|
|
45
|
+
arch,
|
|
46
|
+
outDir,
|
|
47
|
+
cacheDir = "./cache",
|
|
48
|
+
cache = true,
|
|
49
|
+
appImageToolUrl = APPIMAGETOOL_URL,
|
|
50
|
+
}) {
|
|
51
|
+
if (process.platform !== "linux") {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`AppImage packaging requires a Linux host, since "appimagetool" is a native Linux binary. Received host platform: ${process.platform}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof appDir !== "string" || appDir === "") {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Expected "options.appDir" to be a non-empty string. Received: ${JSON.stringify(appDir)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ((await util.fileExists(appDir)) === false) {
|
|
64
|
+
throw new Error(`"options.appDir" does not exist: ${appDir}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof appName !== "string" || appName === "") {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Expected "options.appName" to be a non-empty string. Received: ${JSON.stringify(appName)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const resolvedArch =
|
|
74
|
+
/** @type {"ia32" | "x64" | "arm64" | undefined} */ (arch) ??
|
|
75
|
+
/** @type {"ia32" | "x64" | "arm64"} */ (
|
|
76
|
+
/** @type {unknown} */ (process.arch)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const appImageToolArch = util.APPIMAGE_ARCH_KV[resolvedArch];
|
|
80
|
+
if (appImageToolArch === undefined) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Expected "options.arch" to be "ia32", "x64" or "arm64". Received: ${JSON.stringify(arch)}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const resolvedAppDir = path.resolve(appDir);
|
|
87
|
+
const executablePath = path.resolve(resolvedAppDir, appName);
|
|
88
|
+
if ((await util.fileExists(executablePath)) === false) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Expected the NW.js executable to exist at ${executablePath}. Does "options.appName" match the "app.name" used to build "options.appDir"?`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const desktopFilePath = path.resolve(resolvedAppDir, `${appName}.desktop`);
|
|
95
|
+
if ((await util.fileExists(desktopFilePath)) === false) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Expected a desktop entry file to exist at ${desktopFilePath}. Build "options.appDir" with "@nwutils/builder" for "platform: \\"linux\\"" first.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const desktopEntry = util.parseDesktopEntry(
|
|
101
|
+
await fs.promises.readFile(desktopFilePath, "utf-8"),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const resolvedIcon = icon ?? desktopEntry.Icon;
|
|
105
|
+
if (resolvedIcon === undefined || resolvedIcon === "") {
|
|
106
|
+
throw new Error(
|
|
107
|
+
'AppImage packaging requires an icon. Pass "options.icon", or ensure the "Icon" key in the desktop entry file points at an existing file.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const resolvedIconPath = path.resolve(resolvedIcon);
|
|
111
|
+
if ((await util.fileExists(resolvedIconPath)) === false) {
|
|
112
|
+
throw new Error(`"options.icon" does not exist: ${resolvedIconPath}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const resolvedOutDir = path.resolve(outDir ?? path.dirname(resolvedAppDir));
|
|
116
|
+
await fs.promises.mkdir(resolvedOutDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
const resolvedCacheDir = path.resolve(cacheDir);
|
|
119
|
+
const appImageToolPath = await getAppImageTool({
|
|
120
|
+
appImageToolArch,
|
|
121
|
+
appImageToolUrl,
|
|
122
|
+
cacheDir: resolvedCacheDir,
|
|
123
|
+
cache,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const appImageDir = await fs.promises.mkdtemp(
|
|
127
|
+
path.join(os.tmpdir(), "nwutils-packager-"),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await fs.promises.cp(resolvedAppDir, appImageDir, {
|
|
132
|
+
recursive: true,
|
|
133
|
+
force: true,
|
|
134
|
+
verbatimSymlinks: true,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const iconFileName = path.basename(resolvedIconPath);
|
|
138
|
+
await fs.promises.cp(
|
|
139
|
+
resolvedIconPath,
|
|
140
|
+
path.resolve(appImageDir, iconFileName),
|
|
141
|
+
{ force: true },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* `Exec` and `Icon` are rewritten unconditionally: `Exec` may be unset or
|
|
146
|
+
* point at something other than the renamed binary, and `Icon` is an
|
|
147
|
+
* absolute build-time path - neither survives being relocated into an
|
|
148
|
+
* AppImage, whose root is what ends up mounted at runtime. `Categories`
|
|
149
|
+
* is only filled in when absent: `appimagetool` refuses to build an
|
|
150
|
+
* AppImage without one, but `@nwutils/builder`'s `app.categories` is
|
|
151
|
+
* optional, so most callers won't have set it.
|
|
152
|
+
*/
|
|
153
|
+
const appImageDesktopEntry = {
|
|
154
|
+
...desktopEntry,
|
|
155
|
+
Name: desktopEntry.Name ?? appName,
|
|
156
|
+
Exec: appName,
|
|
157
|
+
Icon: path.basename(iconFileName, path.extname(iconFileName)),
|
|
158
|
+
Categories: desktopEntry.Categories || "Utility;",
|
|
159
|
+
};
|
|
160
|
+
await fs.promises.writeFile(
|
|
161
|
+
path.resolve(appImageDir, `${appName}.desktop`),
|
|
162
|
+
util.stringifyDesktopEntry(appImageDesktopEntry),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const appRunPath = path.resolve(appImageDir, "AppRun");
|
|
166
|
+
await fs.promises.writeFile(
|
|
167
|
+
appRunPath,
|
|
168
|
+
[
|
|
169
|
+
"#!/bin/sh",
|
|
170
|
+
'HERE="$(dirname "$(readlink -f "${0}")")"',
|
|
171
|
+
`exec "\${HERE}/${appName}" "$@"`,
|
|
172
|
+
"",
|
|
173
|
+
].join("\n"),
|
|
174
|
+
);
|
|
175
|
+
await fs.promises.chmod(appRunPath, 0o755);
|
|
176
|
+
|
|
177
|
+
const appImageFilePath = path.resolve(
|
|
178
|
+
resolvedOutDir,
|
|
179
|
+
`${appName}-${appImageToolArch}.AppImage`,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
child_process.execFileSync(
|
|
183
|
+
appImageToolPath,
|
|
184
|
+
["--no-appstream", appImageDir, appImageFilePath],
|
|
185
|
+
{
|
|
186
|
+
env: {
|
|
187
|
+
...process.env,
|
|
188
|
+
ARCH: appImageToolArch,
|
|
189
|
+
/*
|
|
190
|
+
* `appimagetool` is itself distributed as an AppImage. Extract and
|
|
191
|
+
* run it directly instead of mounting it via FUSE, which is
|
|
192
|
+
* frequently unavailable in containers, CI runners and sandboxes.
|
|
193
|
+
*/
|
|
194
|
+
APPIMAGE_EXTRACT_AND_RUN: "1",
|
|
195
|
+
},
|
|
196
|
+
stdio: "inherit",
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return appImageFilePath;
|
|
201
|
+
} finally {
|
|
202
|
+
await fs.promises.rm(appImageDir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Download (or reuse a cached) `appimagetool` binary for `appImageToolArch`.
|
|
208
|
+
* @async
|
|
209
|
+
* @function
|
|
210
|
+
* @param {object} options
|
|
211
|
+
* @param {string} options.appImageToolArch `appimagetool` architecture identifier, eg. "x86_64".
|
|
212
|
+
* @param {string} options.appImageToolUrl Base URL to download `appimagetool-<arch>.AppImage` from.
|
|
213
|
+
* @param {string} options.cacheDir Directory used to cache the downloaded binary.
|
|
214
|
+
* @param {boolean} options.cache If true, reuse a cached binary. Otherwise redownload it.
|
|
215
|
+
* @returns {Promise<string>} Path to an executable `appimagetool` binary.
|
|
216
|
+
*/
|
|
217
|
+
async function getAppImageTool({
|
|
218
|
+
appImageToolArch,
|
|
219
|
+
appImageToolUrl,
|
|
220
|
+
cacheDir,
|
|
221
|
+
cache,
|
|
222
|
+
}) {
|
|
223
|
+
const appImageToolPath = path.resolve(
|
|
224
|
+
cacheDir,
|
|
225
|
+
`appimagetool-${appImageToolArch}.AppImage`,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (cache === false) {
|
|
229
|
+
await fs.promises.rm(appImageToolPath, { force: true });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if ((await util.fileExists(appImageToolPath)) === false) {
|
|
233
|
+
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
234
|
+
await request(
|
|
235
|
+
`${appImageToolUrl}/appimagetool-${appImageToolArch}.AppImage`,
|
|
236
|
+
appImageToolPath,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
/*
|
|
240
|
+
* `appimagetool-<arch>.AppImage` has no published checksum to verify
|
|
241
|
+
* against (the "continuous" release is rebuilt in place). Checking the
|
|
242
|
+
* ELF magic bytes at least catches an HTML error page or truncated
|
|
243
|
+
* download being saved and later executed as though it were the tool.
|
|
244
|
+
*/
|
|
245
|
+
const magic = Buffer.alloc(4);
|
|
246
|
+
const fileHandle = await fs.promises.open(appImageToolPath, "r");
|
|
247
|
+
try {
|
|
248
|
+
await fileHandle.read(magic, 0, 4, 0);
|
|
249
|
+
} finally {
|
|
250
|
+
await fileHandle.close();
|
|
251
|
+
}
|
|
252
|
+
if (magic.toString("hex") !== "7f454c46") {
|
|
253
|
+
await fs.promises.rm(appImageToolPath, { force: true });
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Downloaded "appimagetool" from ${appImageToolUrl}/appimagetool-${appImageToolArch}.AppImage does not look like an ELF binary.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await fs.promises.chmod(appImageToolPath, 0o755);
|
|
261
|
+
|
|
262
|
+
return appImageToolPath;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export default appImage;
|
|
266
|
+
export { getAppImageTool };
|
package/src/index.js
CHANGED
package/src/request.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { URL } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maximum number of HTTP redirects to follow before giving up.
|
|
9
|
+
* @type {number}
|
|
10
|
+
*/
|
|
11
|
+
const MAX_REDIRECTS = 5;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Download from `url` and save at `filePath`.
|
|
15
|
+
* @param {string} url
|
|
16
|
+
* @param {string} filePath
|
|
17
|
+
* @param {number} [redirectCount] - Number of redirects already followed. Used internally for recursive calls.
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
export default function request(url, filePath, redirectCount = 0) {
|
|
21
|
+
const parsedUrl = new URL(url);
|
|
22
|
+
const client = parsedUrl.protocol === "https:" ? https : http;
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const writeStream = fs.createWriteStream(filePath);
|
|
26
|
+
|
|
27
|
+
/* Handle writeStream errors immediately */
|
|
28
|
+
writeStream.on("error", (err) => {
|
|
29
|
+
cleanup();
|
|
30
|
+
reject(err);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/* Ctrl+C cleanup */
|
|
34
|
+
const onSigInt = () => {
|
|
35
|
+
writeStream.destroy();
|
|
36
|
+
/*
|
|
37
|
+
* Unlink unconditionally and swallow ENOENT: checking with existsSync
|
|
38
|
+
* first is a check-then-act race (CWE-367) - the file can vanish between
|
|
39
|
+
* the two calls, and on SIGINT there is no useful recovery either way.
|
|
40
|
+
*/
|
|
41
|
+
try {
|
|
42
|
+
fs.unlinkSync(filePath);
|
|
43
|
+
} catch {
|
|
44
|
+
/* Nothing to clean up. */
|
|
45
|
+
}
|
|
46
|
+
process.exit();
|
|
47
|
+
};
|
|
48
|
+
process.once("SIGINT", onSigInt);
|
|
49
|
+
|
|
50
|
+
const req = client.get(
|
|
51
|
+
{
|
|
52
|
+
hostname: parsedUrl.hostname,
|
|
53
|
+
port: parsedUrl.port || (parsedUrl.protocol === "https:" ? 443 : 80),
|
|
54
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
55
|
+
headers: {
|
|
56
|
+
"User-Agent": "node:http(s)",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
(res) => {
|
|
60
|
+
/* Redirect handling */
|
|
61
|
+
if (
|
|
62
|
+
res.statusCode &&
|
|
63
|
+
res.statusCode >= 300 &&
|
|
64
|
+
res.statusCode < 400 &&
|
|
65
|
+
res.headers.location
|
|
66
|
+
) {
|
|
67
|
+
cleanup();
|
|
68
|
+
|
|
69
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
70
|
+
return reject(
|
|
71
|
+
new Error(
|
|
72
|
+
`Too many redirects (> ${MAX_REDIRECTS}) while requesting ${url}`,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const redirectedUrl = new URL(res.headers.location, parsedUrl);
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
* A redirect response can be injected by a network attacker even
|
|
81
|
+
* when the original request can't be, so refuse one that would
|
|
82
|
+
* silently strip transport security from the rest of the chain.
|
|
83
|
+
*/
|
|
84
|
+
if (
|
|
85
|
+
parsedUrl.protocol === "https:" &&
|
|
86
|
+
redirectedUrl.protocol === "http:"
|
|
87
|
+
) {
|
|
88
|
+
return reject(
|
|
89
|
+
new Error(
|
|
90
|
+
`Refusing to follow redirect from ${url} to ${redirectedUrl.toString()}: downgrades from https to http.`,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return resolve(
|
|
96
|
+
request(redirectedUrl.toString(), filePath, redirectCount + 1),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (res.statusCode !== 200) {
|
|
101
|
+
cleanup();
|
|
102
|
+
return reject(
|
|
103
|
+
new Error(`Request failed. Status code: ${res.statusCode}`),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
res.pipe(writeStream);
|
|
108
|
+
|
|
109
|
+
writeStream.on("finish", () => {
|
|
110
|
+
cleanup();
|
|
111
|
+
resolve();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
res.on("error", (err) => {
|
|
115
|
+
cleanup();
|
|
116
|
+
reject(err);
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
req.on("error", (err) => {
|
|
122
|
+
cleanup();
|
|
123
|
+
reject(err);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function cleanup() {
|
|
127
|
+
process.removeListener("SIGINT", onSigInt);
|
|
128
|
+
req.destroy();
|
|
129
|
+
writeStream.destroy();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map nw-builder's architecture identifiers to the identifiers used in
|
|
5
|
+
* `appimagetool` release asset names and its `ARCH` environment variable.
|
|
6
|
+
* @type {Record<"ia32" | "x64" | "arm64", string>}
|
|
7
|
+
*/
|
|
8
|
+
const APPIMAGE_ARCH_KV = {
|
|
9
|
+
ia32: "i686",
|
|
10
|
+
x64: "x86_64",
|
|
11
|
+
arm64: "aarch64",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a file or directory exists.
|
|
16
|
+
* @async
|
|
17
|
+
* @function
|
|
18
|
+
* @param {string} filePath
|
|
19
|
+
* @returns {Promise<boolean>}
|
|
20
|
+
*/
|
|
21
|
+
async function fileExists(filePath) {
|
|
22
|
+
let exists = true;
|
|
23
|
+
try {
|
|
24
|
+
await fs.promises.stat(filePath);
|
|
25
|
+
} catch {
|
|
26
|
+
exists = false;
|
|
27
|
+
}
|
|
28
|
+
return exists;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a freedesktop.org desktop entry file's `[Desktop Entry]` group into
|
|
33
|
+
* a key/value map. Other groups (eg. `[Desktop Action ...]`) are ignored.
|
|
34
|
+
* @param {string} contents
|
|
35
|
+
* @returns {Record<string, string>}
|
|
36
|
+
*/
|
|
37
|
+
function parseDesktopEntry(contents) {
|
|
38
|
+
/** @type {Record<string, string>} */
|
|
39
|
+
const entries = {};
|
|
40
|
+
let inDesktopEntryGroup = false;
|
|
41
|
+
|
|
42
|
+
for (const rawLine of contents.split("\n")) {
|
|
43
|
+
const line = rawLine.trim();
|
|
44
|
+
if (line === "" || line.startsWith("#")) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (line.startsWith("[")) {
|
|
48
|
+
inDesktopEntryGroup = line === "[Desktop Entry]";
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (inDesktopEntryGroup === false) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const separatorIndex = line.indexOf("=");
|
|
55
|
+
if (separatorIndex === -1) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
59
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
60
|
+
entries[key] = value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return entries;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Serialise a `[Desktop Entry]` key/value map back into a desktop entry file.
|
|
68
|
+
* @param {Record<string, string | undefined>} entries
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function stringifyDesktopEntry(entries) {
|
|
72
|
+
let fileContent = "[Desktop Entry]\n";
|
|
73
|
+
for (const key of Object.keys(entries)) {
|
|
74
|
+
if (entries[key] !== undefined) {
|
|
75
|
+
fileContent += `${key}=${entries[key]}\n`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return fileContent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default {
|
|
82
|
+
APPIMAGE_ARCH_KV,
|
|
83
|
+
fileExists,
|
|
84
|
+
parseDesktopEntry,
|
|
85
|
+
stringifyDesktopEntry,
|
|
86
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type AppImageOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"`.
|
|
4
|
+
*/
|
|
5
|
+
appDir: string;
|
|
6
|
+
/**
|
|
7
|
+
* Name of the application. Must match the `app.name` value used to build `appDir` - the executable and desktop entry are expected at `<appDir>/<appName>` and `<appDir>/<appName>.desktop`.
|
|
8
|
+
*/
|
|
9
|
+
appName: string;
|
|
10
|
+
/**
|
|
11
|
+
* Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop`.
|
|
12
|
+
*/
|
|
13
|
+
icon?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Target architecture. Defaults to the host architecture. `appimagetool` runs as a native binary, so this must match the host unless the host has emulation (eg. QEMU/binfmt) configured for the target architecture.
|
|
16
|
+
*/
|
|
17
|
+
arch?: "ia32" | "x64" | "arm64";
|
|
18
|
+
/**
|
|
19
|
+
* Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir`.
|
|
20
|
+
*/
|
|
21
|
+
outDir?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Directory used to cache the downloaded `appimagetool` binary.
|
|
24
|
+
*/
|
|
25
|
+
cacheDir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* If true, reuse a cached `appimagetool` binary. Otherwise redownload it.
|
|
28
|
+
*/
|
|
29
|
+
cache?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release.
|
|
32
|
+
*/
|
|
33
|
+
appImageToolUrl?: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} AppImageOptions
|
|
37
|
+
* @property {string} appDir Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"`.
|
|
38
|
+
* @property {string} appName Name of the application. Must match the `app.name` value used to build `appDir` - the executable and desktop entry are expected at `<appDir>/<appName>` and `<appDir>/<appName>.desktop`.
|
|
39
|
+
* @property {string} [icon] Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop`.
|
|
40
|
+
* @property {"ia32" | "x64" | "arm64"} [arch] Target architecture. Defaults to the host architecture. `appimagetool` runs as a native binary, so this must match the host unless the host has emulation (eg. QEMU/binfmt) configured for the target architecture.
|
|
41
|
+
* @property {string} [outDir] Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir`.
|
|
42
|
+
* @property {string} [cacheDir="./cache"] Directory used to cache the downloaded `appimagetool` binary.
|
|
43
|
+
* @property {boolean} [cache=true] If true, reuse a cached `appimagetool` binary. Otherwise redownload it.
|
|
44
|
+
* @property {string} [appImageToolUrl] Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Package a built NW.js Linux application as an AppImage.
|
|
48
|
+
*
|
|
49
|
+
* This composes with `@nwutils/builder`: run it against the `outDir` (and
|
|
50
|
+
* `app.name`) that `@nwutils/builder` produced for `platform: "linux"`.
|
|
51
|
+
* @async
|
|
52
|
+
* @function
|
|
53
|
+
* @param {AppImageOptions} options
|
|
54
|
+
* @returns {Promise<string>} Path to the resulting `.AppImage` file.
|
|
55
|
+
*/
|
|
56
|
+
declare function appImage({ appDir, appName, icon, arch, outDir, cacheDir, cache, appImageToolUrl, }: AppImageOptions): Promise<string>;
|
|
57
|
+
/**
|
|
58
|
+
* Download (or reuse a cached) `appimagetool` binary for `appImageToolArch`.
|
|
59
|
+
* @async
|
|
60
|
+
* @function
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* @param {string} options.appImageToolArch `appimagetool` architecture identifier, eg. "x86_64".
|
|
63
|
+
* @param {string} options.appImageToolUrl Base URL to download `appimagetool-<arch>.AppImage` from.
|
|
64
|
+
* @param {string} options.cacheDir Directory used to cache the downloaded binary.
|
|
65
|
+
* @param {boolean} options.cache If true, reuse a cached binary. Otherwise redownload it.
|
|
66
|
+
* @returns {Promise<string>} Path to an executable `appimagetool` binary.
|
|
67
|
+
*/
|
|
68
|
+
declare function getAppImageTool({ appImageToolArch, appImageToolUrl, cacheDir, cache, }: {
|
|
69
|
+
appImageToolArch: string;
|
|
70
|
+
appImageToolUrl: string;
|
|
71
|
+
cacheDir: string;
|
|
72
|
+
cache: boolean;
|
|
73
|
+
}): Promise<string>;
|
|
74
|
+
export default appImage;
|
|
75
|
+
export { getAppImageTool };
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download from `url` and save at `filePath`.
|
|
3
|
+
* @param {string} url
|
|
4
|
+
* @param {string} filePath
|
|
5
|
+
* @param {number} [redirectCount] - Number of redirects already followed. Used internally for recursive calls.
|
|
6
|
+
* @returns {Promise<void>}
|
|
7
|
+
*/
|
|
8
|
+
export default function request(url: string, filePath: string, redirectCount?: number): Promise<void>;
|
package/types/util.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a file or directory exists.
|
|
3
|
+
* @async
|
|
4
|
+
* @function
|
|
5
|
+
* @param {string} filePath
|
|
6
|
+
* @returns {Promise<boolean>}
|
|
7
|
+
*/
|
|
8
|
+
declare function fileExists(filePath: string): Promise<boolean>;
|
|
9
|
+
/**
|
|
10
|
+
* Parse a freedesktop.org desktop entry file's `[Desktop Entry]` group into
|
|
11
|
+
* a key/value map. Other groups (eg. `[Desktop Action ...]`) are ignored.
|
|
12
|
+
* @param {string} contents
|
|
13
|
+
* @returns {Record<string, string>}
|
|
14
|
+
*/
|
|
15
|
+
declare function parseDesktopEntry(contents: string): Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Serialise a `[Desktop Entry]` key/value map back into a desktop entry file.
|
|
18
|
+
* @param {Record<string, string | undefined>} entries
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
declare function stringifyDesktopEntry(entries: Record<string, string | undefined>): string;
|
|
22
|
+
declare const _default: {
|
|
23
|
+
APPIMAGE_ARCH_KV: Record<"arm64" | "ia32" | "x64", string>;
|
|
24
|
+
fileExists: typeof fileExists;
|
|
25
|
+
parseDesktopEntry: typeof parseDesktopEntry;
|
|
26
|
+
stringifyDesktopEntry: typeof stringifyDesktopEntry;
|
|
27
|
+
};
|
|
28
|
+
export default _default;
|