@asher-1/acloudviewer 0.0.0-stage → 3.9.5
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 +113 -2
- package/bin/acloudviewer.js +836 -0
- package/package.json +30 -4
package/README.md
CHANGED
|
@@ -1,3 +1,114 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @asher-1/acloudviewer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cross-platform **installer bootstrap** for [ACloudViewer](https://asher-1.github.io/ACloudViewer/) — an open-source 3D point cloud and mesh processing application.
|
|
4
|
+
|
|
5
|
+
The npm package itself is tiny (~KB): it detects your platform, downloads the matching official installer (`.exe` / `.dmg` / `.run`) **or** Python wheel from [GitHub Releases](https://github.com/Asher-1/ACloudViewer/releases), and launches it. The binary distribution always comes from GitHub Releases — npm never ships the large binaries.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
### Channel 1: npmjs.org (no authentication, recommended)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @asher-1/acloudviewer
|
|
13
|
+
acloudviewer install # downloads + launches the installer for your platform
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Channel 2: GitHub Packages (mirror)
|
|
17
|
+
|
|
18
|
+
GitHub Packages requires a token to install **any** package, including public ones. One-time setup:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# 1. Create a PAT (classic) with read:packages at
|
|
22
|
+
# https://github.com/settings/tokens/new?scopes=read:packages
|
|
23
|
+
# 2. Configure npm once:
|
|
24
|
+
npm config set @asher-1:registry https://npm.pkg.github.com/
|
|
25
|
+
npm config set //npm.pkg.github.com/:_authToken=<YOUR_TOKEN>
|
|
26
|
+
# 3. Install:
|
|
27
|
+
npm install -g @asher-1/acloudviewer
|
|
28
|
+
acloudviewer install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
No Node.js on the target machine? Any user can also download installers directly at https://github.com/Asher-1/ACloudViewer/releases.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
acloudviewer install [--cuda|--cpu] [--silent] [--prefix <dir>] [--parallel <n>] [--dry-run] [--release <tag>]
|
|
37
|
+
acloudviewer install --wheel [--cpu-only] [--python <path>] [--release <tag>]
|
|
38
|
+
acloudviewer list [--release <tag>]
|
|
39
|
+
acloudviewer uninstall [--prefix <dir>] [--yes]
|
|
40
|
+
acloudviewer version | help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Option | Meaning |
|
|
44
|
+
|--------|---------|
|
|
45
|
+
| `--cuda` / `--cpu` | Force build flavor; default auto-detects an NVIDIA GPU (CUDA build falls back to CPU automatically). |
|
|
46
|
+
| `--silent` | Unattended install (QtIFW console mode; the app lands in `--prefix`, default `~/.local/share/ACloudViewer` — no sudo). |
|
|
47
|
+
| `--prefix <dir>` | Install root for `--silent` / `--from`, and the target for `uninstall`. |
|
|
48
|
+
| `--parallel <n>` | Concurrent download segments, 1–16 (see *Download behavior*). |
|
|
49
|
+
| `--wheel` | Install the Python `cloudViewer` wheel via pip instead of the desktop app (requires Python 3.10+). |
|
|
50
|
+
| `--dry-run` | Print the matched asset and download URL, then exit (no download). |
|
|
51
|
+
| `--release <tag>` | GitHub release tag; defaults to the rolling `main-devel` release. |
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
acloudviewer list # show all assets in the current release
|
|
57
|
+
acloudviewer install --silent # unattended desktop install
|
|
58
|
+
acloudviewer install --wheel # pip-install the cloudViewer Python package
|
|
59
|
+
acloudviewer uninstall --yes # remove the installation via maintenancetool
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Download behavior
|
|
63
|
+
|
|
64
|
+
Large assets (>= 64 MB) are fetched as **4 parallel ranged segments** written
|
|
65
|
+
into one preallocated file — release assets are served by a CDN that
|
|
66
|
+
throttles **per connection**, so segments download in parallel and aggregate
|
|
67
|
+
throughput roughly 2–3x (measured on the release CDN; override with
|
|
68
|
+
`--parallel 1..16`). Any stream that receives no bytes for 60s is aborted and
|
|
69
|
+
retried, and every retry **resumes from the bytes already on disk** (HTTP
|
|
70
|
+
`Range`) instead of restarting the whole file. If the server does not honor
|
|
71
|
+
range requests (a `200` reply to a ranged request), the downloader
|
|
72
|
+
automatically falls back to a single stream. Downloads land in a temp dir and
|
|
73
|
+
are removed after install.
|
|
74
|
+
|
|
75
|
+
## Uninstall
|
|
76
|
+
|
|
77
|
+
`uninstall` runs the QtIFW **maintenancetool** of the installation directory
|
|
78
|
+
(`purge --confirm-command`) — the same uninstaller the install wizard
|
|
79
|
+
provides, verified end-to-end. Without `--yes` it asks for confirmation on a
|
|
80
|
+
terminal; in a non-interactive shell (scripts, agents, CI) it **refuses** to
|
|
81
|
+
run unless `--yes` is given, so automation can never be surprised by it.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
acloudviewer uninstall # interactive confirmation
|
|
85
|
+
acloudviewer uninstall --yes # unattended
|
|
86
|
+
acloudviewer uninstall --prefix ~/opt/ACloudViewer --yes # non-default location
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## What it does per platform
|
|
90
|
+
|
|
91
|
+
| Platform | Default | `--silent` |
|
|
92
|
+
|----------|---------|-----------|
|
|
93
|
+
| Windows | Runs the QtIFW `.exe` wizard | QtIFW headless install to `--prefix` |
|
|
94
|
+
| macOS | Opens the `.dmg` in Finder (drag to Applications) | Runs the bundled installer headless (`hdiutil` attach → install → detach) |
|
|
95
|
+
| Linux | Runs the QtIFW `.run` wizard | QtIFW headless install to `--prefix` |
|
|
96
|
+
|
|
97
|
+
Silent installs mirror the pip harness's production-verified QtIFW console
|
|
98
|
+
invocations (`--root <dir> --accept-licenses --confirm-command install`), so
|
|
99
|
+
the same uninstaller/maintenance tooling applies. The GUI default keeps the
|
|
100
|
+
exact flow of downloading from the releases page.
|
|
101
|
+
|
|
102
|
+
## Troubleshooting: `acloudviewer: command not found`
|
|
103
|
+
|
|
104
|
+
`npm install -g` places the binary in npm's global `bin` directory
|
|
105
|
+
(`npm prefix -g`/`bin`). If that directory is not on your `PATH` (common with
|
|
106
|
+
custom npm prefixes), the command exists but the shell cannot find it:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
export PATH="$(npm prefix -g)/bin:$PATH" # add to ~/.bashrc to persist
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
GPL-2.0-only — same as [ACloudViewer](https://github.com/Asher-1/ACloudViewer).
|
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @asher-1/acloudviewer — cross-platform installer bootstrap.
|
|
4
|
+
*
|
|
5
|
+
* Fetches the matching official ACloudViewer installer (.exe/.dmg/.run) or
|
|
6
|
+
* Python wheel from the GitHub release and installs it. Zero dependencies:
|
|
7
|
+
* Node >= 18 built-ins only (fetch, fs, child_process).
|
|
8
|
+
*
|
|
9
|
+
* Asset-matching rules mirror cli_anything/acloudviewer/utils/installer.py;
|
|
10
|
+
* keep the two in sync when release asset names change.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const os = require("os");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const { spawn, spawnSync } = require("child_process");
|
|
19
|
+
const { Readable } = require("stream");
|
|
20
|
+
|
|
21
|
+
const GITHUB_REPO = "Asher-1/ACloudViewer";
|
|
22
|
+
const RELEASE_API = `https://api.github.com/repos/${GITHUB_REPO}/releases/tags/`;
|
|
23
|
+
const DEFAULT_RELEASE = "main-devel";
|
|
24
|
+
const USER_AGENT = "acloudviewer-npm-bootstrap";
|
|
25
|
+
// Same default install root as installer.py DEFAULT_APP_DIR: user-writable,
|
|
26
|
+
// no sudo needed for --silent installs.
|
|
27
|
+
const DEFAULT_TARGET = path.join(os.homedir(), ".local", "share", "ACloudViewer");
|
|
28
|
+
|
|
29
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function die(msg) {
|
|
32
|
+
console.error(`error: ${msg}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isWindows() { return process.platform === "win32"; }
|
|
37
|
+
function isMacOS() { return process.platform === "darwin"; }
|
|
38
|
+
function isLinux() { return process.platform === "linux"; }
|
|
39
|
+
|
|
40
|
+
function archTag() {
|
|
41
|
+
// Mirror installer.py PlatformInfo.arch_tag.
|
|
42
|
+
return process.arch === "arm64" ? "ARM64" : "amd64";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function macArchTag() {
|
|
46
|
+
// Mirror installer.py _match_macos_wheel tag preference.
|
|
47
|
+
return process.arch === "arm64" ? "arm64" : "x86_64";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectNvidiaGpu() {
|
|
51
|
+
const r = spawnSync("nvidia-smi", ["-L"], { timeout: 5000 });
|
|
52
|
+
return r.status === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function detectGlibcVersion() {
|
|
56
|
+
if (!isLinux()) return "";
|
|
57
|
+
const r = spawnSync("ldd", ["--version"], { encoding: "utf8" });
|
|
58
|
+
if (r.status !== 0 || !r.stdout) return "";
|
|
59
|
+
const m = r.stdout.split("\n")[0].match(/(\d+\.\d+)/);
|
|
60
|
+
return m ? m[1] : "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function detectLinuxDistro() {
|
|
64
|
+
if (!isLinux()) return { id: "unknown", version: "" };
|
|
65
|
+
try {
|
|
66
|
+
const text = fs.readFileSync("/etc/os-release", "utf8");
|
|
67
|
+
const info = {};
|
|
68
|
+
for (const line of text.split("\n")) {
|
|
69
|
+
const eq = line.indexOf("=");
|
|
70
|
+
if (eq > 0) info[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^"|"$/g, "");
|
|
71
|
+
}
|
|
72
|
+
return { id: info.ID || "unknown", version: info.VERSION_ID || "" };
|
|
73
|
+
} catch {
|
|
74
|
+
return { id: "unknown", version: "" };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function detectPythonInterpreter(explicit) {
|
|
79
|
+
// Windows: try the py launcher first (always on PATH when the python.org
|
|
80
|
+
// installer was used), then bare interpreters.
|
|
81
|
+
const candidates = explicit ? [explicit]
|
|
82
|
+
: (isWindows() ? ["py", "python", "python3"] : ["python3", "python"]);
|
|
83
|
+
for (const exe of candidates) {
|
|
84
|
+
const r = spawnSync(exe, ["--version"], { encoding: "utf8" });
|
|
85
|
+
const out = `${r.stdout || ""}${r.stderr || ""}`;
|
|
86
|
+
const m = out.match(/Python\s+(\d+)\.(\d+)/);
|
|
87
|
+
if (r.status === 0 && m) {
|
|
88
|
+
return { exe, tag: `cp${m[1]}${m[2]}`, version: `${m[1]}.${m[2]}` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function closestUbuntuTag(glibcVersion) {
|
|
95
|
+
// Mirror installer.py _closest_ubuntu_tag.
|
|
96
|
+
const map = { "2.27": "ubuntu18.04", "2.31": "ubuntu20.04", "2.35": "ubuntu22.04", "2.39": "ubuntu24.04" };
|
|
97
|
+
if (map[glibcVersion]) return map[glibcVersion];
|
|
98
|
+
const ver = parseFloat(glibcVersion);
|
|
99
|
+
if (!Number.isFinite(ver)) return "ubuntu20.04";
|
|
100
|
+
if (ver < 2.31) return "ubuntu18.04";
|
|
101
|
+
if (ver < 2.35) return "ubuntu20.04";
|
|
102
|
+
if (ver < 2.39) return "ubuntu22.04";
|
|
103
|
+
return "ubuntu24.04";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Release assets ───────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async function fetchReleaseAssets(tag) {
|
|
109
|
+
// 30s hard timeout: a hung api.github.com call must not wedge the CLI.
|
|
110
|
+
let res;
|
|
111
|
+
try {
|
|
112
|
+
res = await fetch(RELEASE_API + tag, {
|
|
113
|
+
headers: { "User-Agent": USER_AGENT, Accept: "application/vnd.github+json" },
|
|
114
|
+
signal: AbortSignal.timeout(30000),
|
|
115
|
+
});
|
|
116
|
+
} catch (e) {
|
|
117
|
+
throw new Error(`cannot reach GitHub API (${e && e.message ? e.message : e}). Check your network and try again.`);
|
|
118
|
+
}
|
|
119
|
+
if (res.status === 403) {
|
|
120
|
+
throw new Error(`GitHub API refused the request for release "${tag}" (HTTP 403). ` +
|
|
121
|
+
"Anonymous API calls are limited to 60/hour per IP — wait and retry, or check that the release exists at " +
|
|
122
|
+
`https://github.com/${GITHUB_REPO}/releases`);
|
|
123
|
+
}
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
throw new Error(`cannot read GitHub release "${tag}" (HTTP ${res.status}). Check https://github.com/${GITHUB_REPO}/releases`);
|
|
126
|
+
}
|
|
127
|
+
let release;
|
|
128
|
+
try {
|
|
129
|
+
release = await res.json();
|
|
130
|
+
} catch {
|
|
131
|
+
throw new Error(`unexpected non-JSON response from the GitHub API for "${tag}".`);
|
|
132
|
+
}
|
|
133
|
+
return release.assets || [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pickFlavor(args, hasNvidia) {
|
|
137
|
+
// installer.py defaults to CUDA and falls back to CPU; --cpu/--cuda override.
|
|
138
|
+
if (args.has("cpu")) return "cpu";
|
|
139
|
+
if (args.has("cuda")) return "cuda";
|
|
140
|
+
return hasNvidia ? "cuda" : "cpu";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function findInstallerApp(assets, plat, flavor) {
|
|
144
|
+
// Mirror installer.py find_matching_app (desktop installers only).
|
|
145
|
+
const isCpu = (n) => n.includes("-cpu-");
|
|
146
|
+
const isCuda = (n) => n.includes("-cuda-");
|
|
147
|
+
|
|
148
|
+
if (isLinux()) {
|
|
149
|
+
const ubuntuTag = plat.distro.id === "ubuntu" && plat.distro.version
|
|
150
|
+
? `ubuntu${plat.distro.version}`
|
|
151
|
+
: closestUbuntuTag(plat.glibc);
|
|
152
|
+
let candidates = assets.filter((a) =>
|
|
153
|
+
a.name.startsWith("ACloudViewer-") && a.name.endsWith(".run") &&
|
|
154
|
+
a.name.includes(ubuntuTag) &&
|
|
155
|
+
(flavor === "cpu" ? isCpu(a.name) : isCuda(a.name)));
|
|
156
|
+
if (!candidates.length && flavor !== "cpu") {
|
|
157
|
+
// CUDA missing -> CPU fallback (same as installer.py).
|
|
158
|
+
candidates = assets.filter((a) =>
|
|
159
|
+
a.name.startsWith("ACloudViewer-") && a.name.endsWith(".run") &&
|
|
160
|
+
a.name.includes(ubuntuTag) && isCpu(a.name));
|
|
161
|
+
}
|
|
162
|
+
return candidates[0] || null;
|
|
163
|
+
}
|
|
164
|
+
if (isMacOS()) {
|
|
165
|
+
// macOS ships CPU-only builds; the arch tag is the discriminator.
|
|
166
|
+
return assets.find((a) =>
|
|
167
|
+
a.name.startsWith("ACloudViewer-") && a.name.endsWith(".dmg") &&
|
|
168
|
+
a.name.includes(plat.macArch)) || null;
|
|
169
|
+
}
|
|
170
|
+
if (isWindows()) {
|
|
171
|
+
let candidates = assets.filter((a) =>
|
|
172
|
+
a.name.startsWith("ACloudViewer-") && a.name.endsWith(".exe") &&
|
|
173
|
+
(flavor === "cpu" ? isCpu(a.name) : isCuda(a.name)));
|
|
174
|
+
if (!candidates.length && flavor !== "cpu") {
|
|
175
|
+
candidates = assets.filter((a) =>
|
|
176
|
+
a.name.startsWith("ACloudViewer-") && a.name.endsWith(".exe") && isCpu(a.name));
|
|
177
|
+
}
|
|
178
|
+
return candidates[0] || null;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function findWheel(assets, plat, py, cpuOnly) {
|
|
184
|
+
// Mirror installer.py find_matching_wheel (+ _match_linux/_macos/_windows).
|
|
185
|
+
const prefix = cpuOnly ? "cloudviewer_cpu-" : "cloudviewer-";
|
|
186
|
+
const hasPyTag = (n) => n.includes(`-${py.tag}-${py.tag}-`);
|
|
187
|
+
|
|
188
|
+
if (isLinux()) {
|
|
189
|
+
const manylinux = plat.glibc
|
|
190
|
+
? `manylinux_${plat.glibc.replace(".", "_")}_${plat.macArch === "ARM64" ? "aarch64" : "x86_64"}`
|
|
191
|
+
: "";
|
|
192
|
+
let exact = assets.filter((a) =>
|
|
193
|
+
a.name.startsWith(prefix) && hasPyTag(a.name) && manylinux && a.name.includes(manylinux));
|
|
194
|
+
if (exact.length) return exact[0];
|
|
195
|
+
const compatible = assets
|
|
196
|
+
.filter((a) => a.name.startsWith(prefix) && hasPyTag(a.name) &&
|
|
197
|
+
a.name.includes("manylinux") && a.name.includes("x86_64"))
|
|
198
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
199
|
+
return compatible[0] || null;
|
|
200
|
+
}
|
|
201
|
+
if (isMacOS()) {
|
|
202
|
+
const matches = assets.filter((a) =>
|
|
203
|
+
a.name.startsWith("cloudviewer-") && !a.name.startsWith("cloudviewer_cpu-") &&
|
|
204
|
+
hasPyTag(a.name) && a.name.includes("macosx") && a.name.includes(plat.macArch));
|
|
205
|
+
if (matches.length) return matches[0];
|
|
206
|
+
const universal = assets.filter((a) =>
|
|
207
|
+
a.name.startsWith("cloudviewer-") && hasPyTag(a.name) && a.name.includes("macosx"));
|
|
208
|
+
return universal[0] || null;
|
|
209
|
+
}
|
|
210
|
+
if (isWindows()) {
|
|
211
|
+
const matches = assets.filter((a) =>
|
|
212
|
+
a.name.startsWith(prefix) && hasPyTag(a.name) && a.name.includes("win_amd64"));
|
|
213
|
+
return matches[0] || null;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Download ─────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
function progressBar(current, total, speed) {
|
|
221
|
+
const width = 30;
|
|
222
|
+
const mb = (n) => (n / 1048576).toFixed(0);
|
|
223
|
+
if (!total) return ` ${(current / 1048576).toFixed(1)} MB downloaded...`;
|
|
224
|
+
const pct = Math.min(Math.floor((current * 100) / total), 100);
|
|
225
|
+
const filled = Math.floor((width * current) / total);
|
|
226
|
+
const bar = "█".repeat(filled) + "░".repeat(width - filled);
|
|
227
|
+
const rate = speed !== undefined ? ` ${speed.toFixed(1)} MB/s` : "";
|
|
228
|
+
return ` [${bar}] ${pct}% ${mb(current)}/${mb(total)} MB${rate}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Aggregated progress over one or many concurrent byte streams. TTY: redraw
|
|
232
|
+
// a \r line at most every 200ms (with speed). Non-TTY (CI logs, pipes): print
|
|
233
|
+
// one line per ~10% (or 10 MB) — \r spam would flood the log.
|
|
234
|
+
function makeProgress(total) {
|
|
235
|
+
const isTTY = process.stdout.isTTY;
|
|
236
|
+
let got = 0, lastPrint = 0, lastGot = 0, lastTime = Date.now();
|
|
237
|
+
const print = (now) => {
|
|
238
|
+
const dt = (now - lastTime) / 1000;
|
|
239
|
+
const speed = dt > 0 ? (got - lastGot) / dt : undefined;
|
|
240
|
+
lastGot = got;
|
|
241
|
+
lastTime = now;
|
|
242
|
+
process.stdout.write(`\r${progressBar(got, total, speed)}`);
|
|
243
|
+
};
|
|
244
|
+
return {
|
|
245
|
+
add(n) {
|
|
246
|
+
got += n;
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
if (isTTY) {
|
|
249
|
+
if (now - lastPrint >= 200) { lastPrint = now; print(now); }
|
|
250
|
+
} else {
|
|
251
|
+
const step = total ? total / 10 : 10 * 1048576;
|
|
252
|
+
if (got - lastGot >= step) {
|
|
253
|
+
lastGot = got;
|
|
254
|
+
lastPrint = now;
|
|
255
|
+
console.log(progressBar(got, total));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
finish() {
|
|
260
|
+
if (isTTY) process.stdout.write("\n");
|
|
261
|
+
else console.log(progressBar(got, total));
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Download engine ──────────────────────────────────────────────────────
|
|
267
|
+
//
|
|
268
|
+
// Zero-dependency downloader with three layers, cheapest first:
|
|
269
|
+
// 1. probe — a Range: bytes=0-0 request discovers size + range support
|
|
270
|
+
// 2. segmented— N parallel Range requests written at absolute offsets
|
|
271
|
+
// (helps when the bottleneck is per-connection throttling;
|
|
272
|
+
// measured 2.4x aggregate on release CDN assets)
|
|
273
|
+
// 3. single — one stream with idle-stall abort + Range resume on retry
|
|
274
|
+
// Every response that proves ranges do not work (200 to a Range request,
|
|
275
|
+
// content-encoding, garbage content-range) degrades to the single stream.
|
|
276
|
+
|
|
277
|
+
const SEGMENT_THRESHOLD = 64 * 1048576; // only segment files >= 64 MB
|
|
278
|
+
const MAX_PARALLEL = 16;
|
|
279
|
+
const STALL_TIMEOUT_MS = 60000; // abort after 60s without any bytes
|
|
280
|
+
const SEGMENT_RETRIES = 8; // with 60s backoff cap: survives a
|
|
281
|
+
// ~4 min network outage mid-download
|
|
282
|
+
|
|
283
|
+
class RangesUnsupportedError extends Error {
|
|
284
|
+
constructor() { super("server does not honor range requests"); }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
288
|
+
|
|
289
|
+
// Human-readable failure reason: fetch wraps low-level errors as
|
|
290
|
+
// "fetch failed" and keeps the real cause (ECONNRESET, getaddrinfo ...)
|
|
291
|
+
// in err.cause — surface it, a bare "fetch failed" is undebuggable.
|
|
292
|
+
function errorReason(e) {
|
|
293
|
+
if (e && e.name === "AbortError") return `stalled (${STALL_TIMEOUT_MS / 1000}s without data)`;
|
|
294
|
+
const cause = e && e.cause && (e.cause.code || e.cause.message)
|
|
295
|
+
? ` (${e.cause.code || e.cause.message})` : "";
|
|
296
|
+
return `${(e && e.message) || String(e)}${cause}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Range probe: 206 + content-range => size + ranges OK; 200 => whole-file
|
|
300
|
+
// reply, ranges unsupported. The body is always cancelled: in the 200 case
|
|
301
|
+
// it IS the entire file, and letting it stream would duplicate the transfer.
|
|
302
|
+
async function probeRange(url) {
|
|
303
|
+
const res = await fetch(url, {
|
|
304
|
+
headers: { "User-Agent": USER_AGENT, Range: "bytes=0-0" },
|
|
305
|
+
redirect: "follow",
|
|
306
|
+
signal: AbortSignal.timeout(30000),
|
|
307
|
+
});
|
|
308
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} while probing ${url}`);
|
|
309
|
+
try {
|
|
310
|
+
if (res.status !== 206) {
|
|
311
|
+
return { ranges: false, total: Number(res.headers.get("content-length") || 0) };
|
|
312
|
+
}
|
|
313
|
+
if (res.headers.get("content-encoding")) return { ranges: false, total: 0 };
|
|
314
|
+
const total = Number(((res.headers.get("content-range") || "").split("/")[1] || "").trim());
|
|
315
|
+
return Number.isFinite(total) && total > 0
|
|
316
|
+
? { ranges: true, total }
|
|
317
|
+
: { ranges: false, total: 0 };
|
|
318
|
+
} finally {
|
|
319
|
+
if (res.body) await res.body.cancel().catch(() => {});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// N parallel Range requests into one preallocated file. Each worker writes
|
|
324
|
+
// its own byte range at absolute offsets via positional fs.writeSync on the
|
|
325
|
+
// shared fd — writeSync is atomic within the event loop and workers never
|
|
326
|
+
// overlap offsets, so no locking is needed. Memory: MAX_PARALLEL workers x
|
|
327
|
+
// stream chunk (~64KB) per segment, i.e. a few MB total, never the file size.
|
|
328
|
+
// why not O(file): total work is O(bytes) but wall time drops ~N-fold under
|
|
329
|
+
// per-connection throttling (measured 2.4x with N=4 on release CDN assets).
|
|
330
|
+
async function downloadSegmented(url, dest, total, segments, progress) {
|
|
331
|
+
const fd = fs.openSync(dest, "w");
|
|
332
|
+
fs.ftruncateSync(fd, total);
|
|
333
|
+
const segSize = Math.ceil(total / segments);
|
|
334
|
+
const segs = [];
|
|
335
|
+
for (let i = 0; i < segments; i++) {
|
|
336
|
+
const start = i * segSize;
|
|
337
|
+
segs.push({ start, end: Math.min(start + segSize, total) - 1, done: 0, ok: false });
|
|
338
|
+
}
|
|
339
|
+
const state = { cancelled: false };
|
|
340
|
+
const results = await Promise.allSettled(segs.map(async (seg, idx) => {
|
|
341
|
+
for (let attempt = 1; attempt <= SEGMENT_RETRIES && !seg.ok; attempt++) {
|
|
342
|
+
const ctrl = new AbortController();
|
|
343
|
+
let timer = setTimeout(() => ctrl.abort(), STALL_TIMEOUT_MS);
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetch(url, {
|
|
346
|
+
headers: { "User-Agent": USER_AGENT, Range: `bytes=${seg.start + seg.done}-${seg.end}` },
|
|
347
|
+
redirect: "follow",
|
|
348
|
+
signal: ctrl.signal,
|
|
349
|
+
});
|
|
350
|
+
if (res.status === 200) throw new RangesUnsupportedError();
|
|
351
|
+
if (res.status !== 206) throw new Error(`HTTP ${res.status} for segment range`);
|
|
352
|
+
if (res.headers.get("content-encoding")) throw new RangesUnsupportedError();
|
|
353
|
+
for await (const chunk of Readable.fromWeb(res.body)) {
|
|
354
|
+
if (state.cancelled) throw new Error("cancelled");
|
|
355
|
+
fs.writeSync(fd, chunk, 0, chunk.length, seg.start + seg.done);
|
|
356
|
+
seg.done += chunk.length;
|
|
357
|
+
progress.add(chunk.length);
|
|
358
|
+
timer.refresh(); // idle timer resets on every received chunk
|
|
359
|
+
}
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
seg.ok = true;
|
|
362
|
+
} catch (e) {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
if (e instanceof RangesUnsupportedError) { state.cancelled = true; throw e; }
|
|
365
|
+
if (attempt < SEGMENT_RETRIES) {
|
|
366
|
+
const wait = Math.min(2 * 2 ** (attempt - 1), 60);
|
|
367
|
+
console.log(` segment ${idx + 1}/${segments}: retry ${attempt}/${SEGMENT_RETRIES - 1} in ${wait}s (${errorReason(e)})`);
|
|
368
|
+
await sleep(wait * 1000);
|
|
369
|
+
} else {
|
|
370
|
+
throw new Error(`segment ${idx + 1}/${segments} (bytes ${seg.start}-${seg.end}) failed after ${SEGMENT_RETRIES} attempts: ${errorReason(e)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}));
|
|
375
|
+
fs.closeSync(fd);
|
|
376
|
+
const unsupported = results.find((r) => r.status === "rejected" && r.reason instanceof RangesUnsupportedError);
|
|
377
|
+
if (unsupported) throw unsupported.reason;
|
|
378
|
+
const failed = results.filter((r) => r.status === "rejected").map((r) => r.reason && r.reason.message);
|
|
379
|
+
if (failed.length) throw new Error(`${failed.length}/${segments} download segment(s) failed:\n - ${failed.join("\n - ")}`);
|
|
380
|
+
const size = fs.statSync(dest).size;
|
|
381
|
+
if (size !== total) throw new Error(`downloaded size mismatch: ${size}/${total} bytes`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Single stream with idle-stall abort and Range resume: on retry the next
|
|
385
|
+
// attempt asks for bytes=<written>- instead of restarting from zero, which
|
|
386
|
+
// matters at low bandwidth (a 278 MB transfer that drops at 90% must not
|
|
387
|
+
// restart). A 200 reply to a resume request means the server ignored the
|
|
388
|
+
// Range header -> truncate and restart cleanly.
|
|
389
|
+
async function downloadSingleStream(url, dest, info, progress, retries = 8) {
|
|
390
|
+
let written = fs.existsSync(dest) ? fs.statSync(dest).size : 0;
|
|
391
|
+
if (written > info.total) written = 0; // stale/larger file: start over
|
|
392
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
393
|
+
let fd = null;
|
|
394
|
+
const ctrl = new AbortController();
|
|
395
|
+
let timer = setTimeout(() => ctrl.abort(), STALL_TIMEOUT_MS);
|
|
396
|
+
try {
|
|
397
|
+
const headers = { "User-Agent": USER_AGENT };
|
|
398
|
+
if (written > 0 && info.ranges) headers.Range = `bytes=${written}-`;
|
|
399
|
+
const res = await fetch(url, { headers, redirect: "follow", signal: ctrl.signal });
|
|
400
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
401
|
+
if (res.headers.get("content-encoding")) {
|
|
402
|
+
throw new Error("unexpected content-encoding on asset download; refusing to write a corrupt file");
|
|
403
|
+
}
|
|
404
|
+
if (res.status === 200 && written > 0) {
|
|
405
|
+
written = 0; // server ignored the resume range
|
|
406
|
+
}
|
|
407
|
+
if (info.total && written === info.total) return; // already complete
|
|
408
|
+
fd = fs.openSync(dest, written > 0 ? "r+" : "w");
|
|
409
|
+
if (written > 0) fs.ftruncateSync(fd, written);
|
|
410
|
+
for await (const chunk of Readable.fromWeb(res.body)) {
|
|
411
|
+
fs.writeSync(fd, chunk, 0, chunk.length, written);
|
|
412
|
+
written += chunk.length;
|
|
413
|
+
progress.add(chunk.length);
|
|
414
|
+
timer.refresh();
|
|
415
|
+
}
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
if (info.total && written < info.total) {
|
|
418
|
+
throw new Error(`connection closed early: ${written}/${info.total} bytes`);
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
} catch (e) {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
if (attempt < retries) {
|
|
424
|
+
const wait = Math.min(5 * 2 ** (attempt - 1), 60);
|
|
425
|
+
console.log(` retry ${attempt}/${retries - 1} in ${wait}s (${errorReason(e)})`);
|
|
426
|
+
await sleep(wait * 1000);
|
|
427
|
+
} else {
|
|
428
|
+
throw new Error(`download failed after ${retries} attempts: ${errorReason(e)}`);
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function downloadWithProgress(url, dest, label, opts = {}) {
|
|
437
|
+
const probe = await probeRange(url);
|
|
438
|
+
const total = probe.total || 0;
|
|
439
|
+
// Segments: explicit --parallel N wins; otherwise auto (4) for large files
|
|
440
|
+
// on range-capable servers. Everything unsupported degrades to 1.
|
|
441
|
+
let segments = opts.parallel || (probe.ranges && total >= SEGMENT_THRESHOLD ? 4 : 1);
|
|
442
|
+
segments = Math.max(1, Math.min(MAX_PARALLEL, Math.floor(segments) || 1));
|
|
443
|
+
if (segments > 1 && (!probe.ranges || (total && total < segments))) {
|
|
444
|
+
console.log(" note: server does not support range requests; single-stream download.");
|
|
445
|
+
segments = 1;
|
|
446
|
+
}
|
|
447
|
+
const progress = makeProgress(total);
|
|
448
|
+
console.log(`Downloading ${label}${total ? ` (${(total / 1048576).toFixed(0)} MB${segments > 1 ? `, ${segments} parallel segments` : ""})` : ""} ...`);
|
|
449
|
+
if (segments > 1) {
|
|
450
|
+
try {
|
|
451
|
+
await downloadSegmented(url, dest, total, segments, progress);
|
|
452
|
+
progress.finish();
|
|
453
|
+
return dest;
|
|
454
|
+
} catch (e) {
|
|
455
|
+
if (e instanceof RangesUnsupportedError) {
|
|
456
|
+
console.log(" note: server did not honor range requests; falling back to a single stream.");
|
|
457
|
+
if (fs.existsSync(dest)) fs.unlinkSync(dest);
|
|
458
|
+
} else {
|
|
459
|
+
throw e;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
await downloadSingleStream(url, dest, { total, ranges: probe.ranges }, progress);
|
|
464
|
+
progress.finish();
|
|
465
|
+
return dest;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Install actions ──────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
function findBinaryInDir(target) {
|
|
471
|
+
// Real QtIFW layout (verified end-to-end on Linux): the binary and its
|
|
472
|
+
// .sh/.bat launcher sit at the target root, no bin/ subdir.
|
|
473
|
+
const name = isWindows() ? "ACloudViewer.exe" : "ACloudViewer";
|
|
474
|
+
for (const p of [path.join(target, name), path.join(target, "bin", name)]) {
|
|
475
|
+
if (fs.existsSync(p)) return p;
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function runInstaller(asset, silent, target) {
|
|
481
|
+
if (isMacOS() && asset.name.endsWith(".dmg")) {
|
|
482
|
+
if (silent) return installDmgSilent(asset, target);
|
|
483
|
+
// Attach synchronously BEFORE returning: the tmp dir holding the dmg is
|
|
484
|
+
// removed right after this call, so the volume must exist by then.
|
|
485
|
+
console.log("Mounting disk image ...");
|
|
486
|
+
const r = spawnSync("hdiutil", ["attach", "-nobrowse", "-readonly", asset.path], { encoding: "utf8" });
|
|
487
|
+
if (r.status !== 0) throw new Error(`hdiutil attach failed: ${(r.stderr || "").slice(0, 300)}`);
|
|
488
|
+
let volume = null;
|
|
489
|
+
for (const line of (r.stdout || "").trim().split("\n")) {
|
|
490
|
+
const parts = line.split("\t");
|
|
491
|
+
if (parts.length >= 3) volume = parts[parts.length - 1].trim();
|
|
492
|
+
}
|
|
493
|
+
if (!volume || !fs.existsSync(volume)) throw new Error("DMG mounted but volume not found.");
|
|
494
|
+
spawn("open", [volume], { detached: true, stdio: "ignore" }).unref();
|
|
495
|
+
console.log(`\nMounted ${volume} — drag ACloudViewer.app to Applications in the Finder window.`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (silent) {
|
|
499
|
+
// Mirror installer.py's production-verified QtIFW console invocations:
|
|
500
|
+
// Linux .run : in --accept-licenses --confirm-command --root <dir>
|
|
501
|
+
// Win/mac : --accept-licenses --accept-messages --confirm-command install --root <dir>
|
|
502
|
+
// All land in --root (user-writable by default) instead of /opt.
|
|
503
|
+
const args = isWindows()
|
|
504
|
+
? ["--root", target, "--accept-licenses", "--accept-messages", "--confirm-command", "install"]
|
|
505
|
+
: ["in", "--accept-licenses", "--confirm-command", "--root", target];
|
|
506
|
+
console.log(`Running silent install (QtIFW console mode) -> ${target}`);
|
|
507
|
+
const env = { ...process.env };
|
|
508
|
+
if (!isWindows()) env.QT_QPA_PLATFORM = env.QT_QPA_PLATFORM || "minimal";
|
|
509
|
+
// QtIFW refuses to install over an existing target (TargetDirectoryInUse).
|
|
510
|
+
// Mirror installer.py's upgrade semantics: remove the previous
|
|
511
|
+
// installation through its own maintenance tool (never a raw rm).
|
|
512
|
+
const mt = path.join(target, isWindows() ? "maintenancetool.exe" : "maintenancetool");
|
|
513
|
+
if (fs.existsSync(mt)) {
|
|
514
|
+
console.log("Existing installation found; uninstalling via maintenancetool purge ...");
|
|
515
|
+
const p = spawnSync(mt, ["purge", "--confirm-command"], { stdio: "inherit", env });
|
|
516
|
+
if (p.status !== 0) {
|
|
517
|
+
throw new Error(`maintenancetool purge exited with code ${p.status}; remove ${target} manually or choose another --prefix.`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
fs.mkdirSync(target, { recursive: true });
|
|
521
|
+
const r = spawnSync(asset.path, args, { stdio: "inherit", env });
|
|
522
|
+
if (r.status !== 0) {
|
|
523
|
+
throw new Error(`installer exited with code ${r.status}; retry without --silent for the GUI wizard, or with --prefix <dir> for a different install root.`);
|
|
524
|
+
}
|
|
525
|
+
const binary = findBinaryInDir(target);
|
|
526
|
+
if (binary) console.log(`\nInstalled. Binary: ${binary}`);
|
|
527
|
+
else console.log(`\nInstalled to ${target} (binary not auto-detected; check contents).`);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (isLinux() && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
531
|
+
console.error("warning: no graphical session detected; the QtIFW wizard may not open. Use --silent for a headless install.");
|
|
532
|
+
}
|
|
533
|
+
const child = spawn(asset.path, [], { detached: true, stdio: "ignore" });
|
|
534
|
+
child.on("error", (e) =>
|
|
535
|
+
console.error(`warning: installer launch failed (${e.message}); download again with 'acloudviewer install' and run it manually.`));
|
|
536
|
+
child.unref();
|
|
537
|
+
console.log(`\nInstaller window opened: ${asset.name}`);
|
|
538
|
+
console.log("Follow the wizard to finish. (Use --silent for unattended install.)");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function installDmgSilent(asset, target) {
|
|
542
|
+
// Mirror installer.py _install_macos_dmg: attach -> run the bundled QtIFW
|
|
543
|
+
// installer headlessly with --root -> detach. Throws (never process.exit)
|
|
544
|
+
// so the tmp-dir cleanup in cmdInstall's finally still runs.
|
|
545
|
+
console.log("Attaching disk image ...");
|
|
546
|
+
const r = spawnSync("hdiutil", ["attach", "-nobrowse", "-readonly", asset.path], { encoding: "utf8" });
|
|
547
|
+
if (r.status !== 0) throw new Error(`hdiutil attach failed: ${(r.stderr || "").slice(0, 300)}`);
|
|
548
|
+
let volume = null;
|
|
549
|
+
for (const line of (r.stdout || "").trim().split("\n")) {
|
|
550
|
+
const parts = line.split("\t");
|
|
551
|
+
if (parts.length >= 3) volume = parts[parts.length - 1].trim();
|
|
552
|
+
}
|
|
553
|
+
if (!volume || !fs.existsSync(volume)) throw new Error("DMG mounted but volume not found.");
|
|
554
|
+
try {
|
|
555
|
+
const apps = fs.readdirSync(volume).filter((n) => n.endsWith(".app"));
|
|
556
|
+
if (!apps.length) throw new Error(`no .app bundle inside ${volume}.`);
|
|
557
|
+
const macosDir = path.join(volume, apps[0], "Contents", "MacOS");
|
|
558
|
+
const installerBin = fs.readdirSync(macosDir).find((n) => {
|
|
559
|
+
try { fs.accessSync(path.join(macosDir, n), fs.constants.X_OK); return true; } catch { return false; }
|
|
560
|
+
});
|
|
561
|
+
if (!installerBin) throw new Error(`no executable installer inside ${apps[0]}.`);
|
|
562
|
+
fs.mkdirSync(target, { recursive: true });
|
|
563
|
+
console.log(`Installing to ${target} (QtIFW headless) ...`);
|
|
564
|
+
const env = { ...process.env, QT_QPA_PLATFORM: process.env.QT_QPA_PLATFORM || "minimal" };
|
|
565
|
+
const run = spawnSync(path.join(macosDir, installerBin),
|
|
566
|
+
["--root", target, "--accept-licenses", "--accept-messages", "--confirm-command", "install"],
|
|
567
|
+
{ stdio: "inherit", env });
|
|
568
|
+
if (run.status !== 0) throw new Error(`installer exited with code ${run.status}. Try opening ${asset.name} manually.`);
|
|
569
|
+
} finally {
|
|
570
|
+
spawnSync("hdiutil", ["detach", volume, "-quiet"]);
|
|
571
|
+
}
|
|
572
|
+
console.log(`Installed. Launch from ${target}.`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function installWheelFile(asset, pythonExe) {
|
|
576
|
+
console.log("Installing wheel with pip ...");
|
|
577
|
+
const r = spawnSync(pythonExe, ["-m", "pip", "install", asset.path], { stdio: "inherit" });
|
|
578
|
+
if (r.status !== 0) throw new Error(`pip install exited with code ${r.status}.`);
|
|
579
|
+
console.log("Installed. Try: python -c \"import cloudViewer; print(cloudViewer.__version__)\"");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── Commands ─────────────────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
function parseParallel(args) {
|
|
585
|
+
const v = args.get("parallel");
|
|
586
|
+
if (v === undefined) return undefined;
|
|
587
|
+
if (typeof v !== "string" || !/^\d+$/.test(v)) {
|
|
588
|
+
throw new Error("--parallel requires a number between 1 and 16 (e.g. --parallel 4).");
|
|
589
|
+
}
|
|
590
|
+
const n = Number(v);
|
|
591
|
+
if (n < 1 || n > MAX_PARALLEL) {
|
|
592
|
+
throw new Error(`--parallel requires a number between 1 and ${MAX_PARALLEL}.`);
|
|
593
|
+
}
|
|
594
|
+
return n;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function cmdInstall(args) {
|
|
598
|
+
const tag = args.get("release") || DEFAULT_RELEASE;
|
|
599
|
+
const silent = args.has("silent");
|
|
600
|
+
const wheel = args.has("wheel");
|
|
601
|
+
const cpuOnly = args.has("cpu") || args.has("cpu-only");
|
|
602
|
+
const parallel = parseParallel(args);
|
|
603
|
+
|
|
604
|
+
// Local-file install (mirrors installer.py install_app_from_file): skips
|
|
605
|
+
// the release query and download entirely. Useful for offline installs
|
|
606
|
+
// and for testing the silent path with an already-downloaded installer.
|
|
607
|
+
const from = args.get("from");
|
|
608
|
+
if (from !== undefined && wheel) {
|
|
609
|
+
throw new Error("--from and --wheel cannot be combined.");
|
|
610
|
+
}
|
|
611
|
+
if (from !== undefined) {
|
|
612
|
+
if (typeof from !== "string" || !from) throw new Error("--from requires a file path argument.");
|
|
613
|
+
if (!fs.existsSync(from)) throw new Error(`file not found: ${from}`);
|
|
614
|
+
const prefixArg = args.get("prefix");
|
|
615
|
+
if (prefixArg && typeof prefixArg !== "string") throw new Error("--prefix requires a directory argument.");
|
|
616
|
+
const asset = { name: path.basename(from), path: from, size: fs.statSync(from).size };
|
|
617
|
+
const target = prefixArg || DEFAULT_TARGET;
|
|
618
|
+
if (args.has("dry-run")) {
|
|
619
|
+
// Zero side effects: no chmod, no installer execution (verified gap:
|
|
620
|
+
// --from previously ignored --dry-run and really launched the file).
|
|
621
|
+
console.log(`would install from local file: ${asset.name} (${(asset.size / 1048576).toFixed(0)} MB) -> ${target}` +
|
|
622
|
+
(silent ? " (silent QtIFW console mode)" : " (GUI wizard)"));
|
|
623
|
+
console.log("dry-run: skipping install.");
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
console.log(`Installing from local file: ${asset.name} (${(asset.size / 1048576).toFixed(0)} MB)`);
|
|
627
|
+
if (isLinux()) fs.chmodSync(from, 0o755);
|
|
628
|
+
runInstaller(asset, silent, target);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const assets = await fetchReleaseAssets(tag);
|
|
633
|
+
|
|
634
|
+
if (wheel) {
|
|
635
|
+
const py = detectPythonInterpreter(args.get("python"));
|
|
636
|
+
if (!py) die("no Python interpreter found; install Python 3.10+ or pass --python <path>.");
|
|
637
|
+
console.log(`Platform: ${process.platform}/${process.arch} Python: ${py.version} (${py.exe})`);
|
|
638
|
+
const asset = findWheel(assets, platformInfo(args), py, cpuOnly);
|
|
639
|
+
if (!asset) die(`no matching wheel for ${py.version} on ${process.platform}/${process.arch}. Run 'acloudviewer list' to see available assets.`);
|
|
640
|
+
console.log(`Matched wheel: ${asset.name} (${(asset.size / 1048576).toFixed(0)} MB)`);
|
|
641
|
+
if (args.has("dry-run")) {
|
|
642
|
+
console.log(`Download URL: ${asset.browser_download_url}`);
|
|
643
|
+
console.log("dry-run: skipping download and pip install.");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "acv-wheel-"));
|
|
647
|
+
try {
|
|
648
|
+
asset.path = await downloadWithProgress(asset.browser_download_url, path.join(tmp, asset.name), asset.name, { parallel });
|
|
649
|
+
installWheelFile(asset, py.exe);
|
|
650
|
+
} finally {
|
|
651
|
+
try {
|
|
652
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
653
|
+
} catch { /* best effort */ }
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const plat = platformInfo(args);
|
|
659
|
+
console.log(`Platform: ${process.platform}/${process.arch}` +
|
|
660
|
+
(isLinux() ? ` distro: ${plat.distro.id} ${plat.distro.version} glibc: ${plat.glibc}` : "") +
|
|
661
|
+
(plat.hasNvidia ? " GPU: NVIDIA detected" : ""));
|
|
662
|
+
const flavor = pickFlavor(args, plat.hasNvidia);
|
|
663
|
+
if (isMacOS() && flavor === "cuda") {
|
|
664
|
+
console.log("note: macOS has no CUDA build; using the CPU installer.");
|
|
665
|
+
}
|
|
666
|
+
const asset = findInstallerApp(assets, plat, isMacOS() ? "cpu" : flavor);
|
|
667
|
+
if (!asset) die(`no matching installer on ${process.platform}/${process.arch} (flavor: ${flavor}). Run 'acloudviewer list' to see available assets.`);
|
|
668
|
+
console.log(`Matched installer: ${asset.name} (${(asset.size / 1048576).toFixed(0)} MB)`);
|
|
669
|
+
if (args.has("dry-run")) {
|
|
670
|
+
console.log(`Download URL: ${asset.browser_download_url}`);
|
|
671
|
+
console.log("dry-run: skipping download and install.");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "acv-install-"));
|
|
676
|
+
try {
|
|
677
|
+
asset.path = await downloadWithProgress(asset.browser_download_url, path.join(tmp, asset.name), asset.name, { parallel });
|
|
678
|
+
if (isLinux()) fs.chmodSync(asset.path, 0o755);
|
|
679
|
+
const prefixArg = args.get("prefix");
|
|
680
|
+
if (prefixArg && typeof prefixArg !== "string") {
|
|
681
|
+
throw new Error("--prefix requires a directory argument.");
|
|
682
|
+
}
|
|
683
|
+
runInstaller(asset, silent, prefixArg || DEFAULT_TARGET);
|
|
684
|
+
} finally {
|
|
685
|
+
// Best-effort cleanup: on Windows a GUI-launched .exe locks its file, so
|
|
686
|
+
// removal may fail — keep the dir and tell the user instead of crashing.
|
|
687
|
+
try {
|
|
688
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
689
|
+
} catch (e) {
|
|
690
|
+
console.log(`note: could not fully clean temp dir ${tmp} (${e.message}); it can be deleted manually.`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function platformInfo(args) {
|
|
696
|
+
const distro = detectLinuxDistro();
|
|
697
|
+
const glibc = detectGlibcVersion();
|
|
698
|
+
return {
|
|
699
|
+
arch: archTag(),
|
|
700
|
+
macArch: macArchTag(),
|
|
701
|
+
glibc,
|
|
702
|
+
distro,
|
|
703
|
+
hasNvidia: args.has("cuda") ? true : (args.has("cpu") ? false : detectNvidiaGpu()),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function mb(bytes) { return (bytes / 1048576).toFixed(0); }
|
|
708
|
+
|
|
709
|
+
async function cmdList(args) {
|
|
710
|
+
const tag = args.get("release") || DEFAULT_RELEASE;
|
|
711
|
+
const assets = await fetchReleaseAssets(tag);
|
|
712
|
+
console.log(`Release "${tag}" on ${GITHUB_REPO}:`);
|
|
713
|
+
const apps = assets.filter((a) => a.name.startsWith("ACloudViewer-"));
|
|
714
|
+
const wheels = assets.filter((a) => a.name.endsWith(".whl"));
|
|
715
|
+
if (apps.length) {
|
|
716
|
+
console.log("\nDesktop installers:");
|
|
717
|
+
for (const a of apps) console.log(` ${a.name} ${mb(a.size)} MB`);
|
|
718
|
+
}
|
|
719
|
+
if (wheels.length) {
|
|
720
|
+
console.log("\nPython wheels:");
|
|
721
|
+
for (const a of wheels) console.log(` ${a.name} ${mb(a.size)} MB`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function cmdUninstall(args) {
|
|
726
|
+
// NOT a mirror of installer.py (which has no uninstall command): this
|
|
727
|
+
// wraps the QtIFW maintenance tool's own purge, whose semantics were
|
|
728
|
+
// verified end-to-end (removes the install dir contents, exit 0).
|
|
729
|
+
const prefixArg = args.get("prefix");
|
|
730
|
+
if (prefixArg && typeof prefixArg !== "string") throw new Error("--prefix requires a directory argument.");
|
|
731
|
+
const target = prefixArg || DEFAULT_TARGET;
|
|
732
|
+
const mtName = isWindows() ? "maintenancetool.exe" : "maintenancetool";
|
|
733
|
+
// Verified real QtIFW layout: maintenancetool sits at the target root;
|
|
734
|
+
// check bin/ too in case a future repack moves it.
|
|
735
|
+
const mt = [path.join(target, mtName), path.join(target, "bin", mtName)]
|
|
736
|
+
.find((p) => fs.existsSync(p));
|
|
737
|
+
if (!mt) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
`no QtIFW maintenance tool found under ${target}. ` +
|
|
740
|
+
"If ACloudViewer is installed in another location, pass --prefix <dir>; " +
|
|
741
|
+
"if it is not installed, there is nothing to uninstall.");
|
|
742
|
+
}
|
|
743
|
+
console.log(`ACloudViewer installation found at: ${target}`);
|
|
744
|
+
console.log(`Uninstaller: ${mt} (purge removes ALL files the installer placed in that directory)`);
|
|
745
|
+
if (!args.has("yes")) {
|
|
746
|
+
// Safety gate: purge is destructive, so a non-interactive shell (agents,
|
|
747
|
+
// CI, piped input) must opt in explicitly instead of being surprised.
|
|
748
|
+
if (!process.stdin.isTTY) {
|
|
749
|
+
throw new Error("refusing to uninstall without confirmation in a non-interactive shell; re-run with --yes.");
|
|
750
|
+
}
|
|
751
|
+
const rl = require("readline").createInterface({ input: process.stdin, output: process.stdout });
|
|
752
|
+
const answer = (await new Promise((resolve) => rl.question("Proceed with uninstall? [y/N] ", resolve))).trim().toLowerCase();
|
|
753
|
+
rl.close();
|
|
754
|
+
if (answer !== "y" && answer !== "yes") {
|
|
755
|
+
console.log("aborted.");
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const env = { ...process.env };
|
|
760
|
+
if (!isWindows()) env.QT_QPA_PLATFORM = env.QT_QPA_PLATFORM || "minimal";
|
|
761
|
+
const r = spawnSync(mt, ["purge", "--confirm-command"], { stdio: "inherit", env });
|
|
762
|
+
if (r.status !== 0) {
|
|
763
|
+
throw new Error(`maintenancetool purge exited with code ${r.status}.`);
|
|
764
|
+
}
|
|
765
|
+
const leftover = fs.existsSync(target) ? fs.readdirSync(target).length : 0;
|
|
766
|
+
console.log(leftover === 0
|
|
767
|
+
? `\nUninstalled. ${target} is gone.`
|
|
768
|
+
: `\nUninstall finished; ${leftover} item(s) remain under ${target} (installer logs or shortcuts the purger does not own).`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function usage() {
|
|
772
|
+
console.log(`ACloudViewer installer bootstrap (npm package @asher-1/acloudviewer)
|
|
773
|
+
|
|
774
|
+
Usage:
|
|
775
|
+
acloudviewer install [--cuda|--cpu] [--silent] [--prefix <dir>] [--parallel <n>] [--release <tag>]
|
|
776
|
+
acloudviewer install --from <installer-file> [--silent] [--prefix <dir>]
|
|
777
|
+
acloudviewer install --wheel [--cpu-only] [--python <path>] [--release <tag>]
|
|
778
|
+
acloudviewer list [--release <tag>]
|
|
779
|
+
acloudviewer uninstall [--prefix <dir>] [--yes]
|
|
780
|
+
acloudviewer version | help
|
|
781
|
+
|
|
782
|
+
Options:
|
|
783
|
+
--cuda / --cpu Force a build flavor (default: auto-detect NVIDIA GPU).
|
|
784
|
+
--silent Unattended install (QtIFW console mode / dmg headless).
|
|
785
|
+
--prefix <dir> Install root for --silent (default:
|
|
786
|
+
~/.local/share/ACloudViewer, same as the pip harness).
|
|
787
|
+
--parallel <n> Concurrent download segments, 1-${MAX_PARALLEL} (default: 4 for
|
|
788
|
+
files >= 64 MB when the server supports ranges; falls
|
|
789
|
+
back to a single stream automatically).
|
|
790
|
+
--wheel Install the Python wheel instead of the desktop app
|
|
791
|
+
(requires Python 3.10+).
|
|
792
|
+
--from <file> Install from a local installer file (.exe/.dmg/.run)
|
|
793
|
+
instead of downloading from GitHub Releases.
|
|
794
|
+
--yes For uninstall: skip the confirmation prompt.
|
|
795
|
+
--dry-run Print the matched asset and URL, then exit.
|
|
796
|
+
--release <tag> GitHub release tag (default: ${DEFAULT_RELEASE}).
|
|
797
|
+
|
|
798
|
+
Downloads land in a temp dir and are removed after install. Segmented
|
|
799
|
+
downloads write into one preallocated file and abort any stream that stalls
|
|
800
|
+
for ${STALL_TIMEOUT_MS / 1000}s; failed attempts resume from the bytes already
|
|
801
|
+
received instead of restarting. Uninstall runs the QtIFW maintenancetool
|
|
802
|
+
("purge") of the installation directory — the same tool the install wizard
|
|
803
|
+
provides.`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// ── Main ─────────────────────────────────────────────────────────────────
|
|
807
|
+
|
|
808
|
+
async function main() {
|
|
809
|
+
const argv = process.argv.slice(2);
|
|
810
|
+
const cmd = argv[0] || "help";
|
|
811
|
+
const args = new Map();
|
|
812
|
+
for (let i = 1; i < argv.length; i++) {
|
|
813
|
+
if (argv[i].startsWith("--")) {
|
|
814
|
+
const next = argv[i + 1];
|
|
815
|
+
if (next && !next.startsWith("--")) { args.set(argv[i].slice(2), next); i++; }
|
|
816
|
+
else args.set(argv[i].slice(2), true);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
switch (cmd) {
|
|
820
|
+
case "install": await cmdInstall(args); break;
|
|
821
|
+
case "list": await cmdList(args); break;
|
|
822
|
+
case "uninstall": await cmdUninstall(args); break;
|
|
823
|
+
case "version": console.log(require("../package.json").version); break;
|
|
824
|
+
case "help": usage(); break;
|
|
825
|
+
default: console.error(`unknown command: ${cmd}\n`); usage(); process.exit(1);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Expose the download engine for tests when require()d as a module; run the
|
|
830
|
+
// CLI only when executed directly.
|
|
831
|
+
if (require.main === module) {
|
|
832
|
+
module.exports = { downloadWithProgress };
|
|
833
|
+
main().catch((err) => die(err && err.message ? err.message : String(err)));
|
|
834
|
+
} else {
|
|
835
|
+
module.exports = { downloadWithProgress };
|
|
836
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asher-1/acloudviewer",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.9.5",
|
|
4
|
+
"description": "Cross-platform installer bootstrap for ACloudViewer — fetches and launches the official Windows/macOS/Linux installer (or Python wheel) from GitHub Releases.",
|
|
5
|
+
"license": "GPL-2.0-only",
|
|
6
|
+
"bin": {
|
|
7
|
+
"acloudviewer": "bin/acloudviewer.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/Asher-1/ACloudViewer.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://asher-1.github.io/ACloudViewer/",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/Asher-1/ACloudViewer/issues"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"acloudviewer",
|
|
26
|
+
"cloudviewer",
|
|
27
|
+
"point-cloud",
|
|
28
|
+
"3d",
|
|
29
|
+
"installer",
|
|
30
|
+
"cli"
|
|
31
|
+
]
|
|
32
|
+
}
|