nixamp 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 +54 -0
- package/bin/nixamp.mjs +4 -1
- package/dist/admin.d.ts +47 -0
- package/dist/admin.js +209 -0
- package/dist/connections.d.ts +66 -0
- package/dist/connections.js +115 -0
- package/dist/daemon.d.ts +39 -0
- package/dist/daemon.js +170 -0
- package/dist/main.js +82 -6
- package/dist/manage.js +28 -6
- package/dist/playlist.d.ts +16 -0
- package/dist/playlist.js +57 -2
- package/dist/server.d.ts +38 -4
- package/dist/server.js +323 -23
- package/dist/share.d.ts +58 -0
- package/dist/share.js +153 -0
- package/dist/sources.d.ts +37 -0
- package/dist/sources.js +125 -0
- package/package.json +1 -1
- package/src/admin.ts +243 -0
- package/src/connections.ts +145 -0
- package/src/daemon.ts +193 -0
- package/src/main.ts +86 -6
- package/src/manage.ts +33 -6
- package/src/playlist.ts +68 -2
- package/src/server.ts +393 -21
- package/src/share.ts +166 -0
- package/src/sources.ts +136 -0
- package/web/dist/install.ps1 +214 -0
- package/web/dist/sw.js +1 -1
package/src/share.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The share link.
|
|
3
|
+
*
|
|
4
|
+
* `nixamp serve` listens on every interface so the phone in your pocket can
|
|
5
|
+
* reach it, and that is only reasonable because the address is not enough on
|
|
6
|
+
* its own: every request has to carry a key that is printed once, in the link.
|
|
7
|
+
* Someone else on the coffee shop wifi can find the port and gets nothing.
|
|
8
|
+
*
|
|
9
|
+
* The key travels in a cookie, set by opening the link. Nothing in the PWA had
|
|
10
|
+
* to change for that: a browser sends a same-origin cookie with every fetch,
|
|
11
|
+
* every EventSource and every `<audio src>` on its own.
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import type { IncomingMessage } from "node:http";
|
|
15
|
+
import { networkInterfaces } from "node:os";
|
|
16
|
+
|
|
17
|
+
/** The cookie, and the query parameter that sets it. */
|
|
18
|
+
export const KEY_COOKIE = "nixamp_key";
|
|
19
|
+
export const KEY_QUERY = "k";
|
|
20
|
+
export const KEY_HEADER = "x-nixamp-key";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 128 bits, base64url. Long enough that guessing is not a strategy, short
|
|
24
|
+
* enough to read down a phone screen when someone types it by hand.
|
|
25
|
+
*/
|
|
26
|
+
export function newKey(): string {
|
|
27
|
+
return randomBytes(16).toString("base64url");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Compare without leaking where two keys first differ. */
|
|
31
|
+
export function keysMatch(a: string, b: string): boolean {
|
|
32
|
+
const left = Buffer.from(a);
|
|
33
|
+
const right = Buffer.from(b);
|
|
34
|
+
// timingSafeEqual throws on a length mismatch, which is itself the answer.
|
|
35
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every place a key is accepted from, in the order they are looked for. */
|
|
39
|
+
export function keyFrom(request: IncomingMessage, url: URL): string | null {
|
|
40
|
+
const query = url.searchParams.get(KEY_QUERY);
|
|
41
|
+
if (query) return query;
|
|
42
|
+
|
|
43
|
+
const header = request.headers[KEY_HEADER];
|
|
44
|
+
if (typeof header === "string" && header) return header;
|
|
45
|
+
|
|
46
|
+
for (const part of (request.headers.cookie ?? "").split(";")) {
|
|
47
|
+
const [name, ...rest] = part.trim().split("=");
|
|
48
|
+
if (name === KEY_COOKIE && rest.length > 0) return decodeURIComponent(rest.join("="));
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The Set-Cookie for a browser that just opened the link. */
|
|
54
|
+
export function keyCookie(key: string): string {
|
|
55
|
+
// HttpOnly because nothing in the page reads it: the browser attaches it to
|
|
56
|
+
// every same-origin request by itself. No Secure, because the whole point is
|
|
57
|
+
// a plain-http address on your own network.
|
|
58
|
+
return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Interfaces that exist for containers and virtual machines. An address on one
|
|
63
|
+
* of these reaches a bridge, not the phone on the sofa, and listing six of them
|
|
64
|
+
* buries the one line someone actually needed.
|
|
65
|
+
*/
|
|
66
|
+
const VIRTUAL = /^(docker|br-|veth|virbr|vmnet|vboxnet|lo)/;
|
|
67
|
+
|
|
68
|
+
/** Where an address actually goes, which is not always where you would like. */
|
|
69
|
+
export function classify(address: string): "private" | "cgnat" | "public" {
|
|
70
|
+
const [a, b] = address.split(".").map(Number) as [number, number];
|
|
71
|
+
if (a === 10) return "private";
|
|
72
|
+
if (a === 192 && b === 168) return "private";
|
|
73
|
+
if (a === 172 && b >= 16 && b <= 31) return "private";
|
|
74
|
+
if (a === 169 && b === 254) return "private";
|
|
75
|
+
// 100.64/10 is carrier-grade NAT, which in practice means Tailscale here.
|
|
76
|
+
if (a === 100 && b >= 64 && b <= 127) return "cgnat";
|
|
77
|
+
return "public";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The addresses another device could actually reach this machine on, nearest
|
|
82
|
+
* first. On a server the public one is the point: it is the address a phone
|
|
83
|
+
* somewhere else can open. It is labelled for what it is, because the key in
|
|
84
|
+
* the link is then the only thing between a stranger and the library.
|
|
85
|
+
*/
|
|
86
|
+
export function reachableAddresses(host: string, port: number): { label: string; url: string }[] {
|
|
87
|
+
const link = (address: string): string => {
|
|
88
|
+
// A bare IPv6 address needs brackets before it is a URL.
|
|
89
|
+
const authority = address.includes(":") ? `[${address}]` : address;
|
|
90
|
+
return `http://${authority}:${port}`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (host !== "0.0.0.0" && host !== "::") return [{ label: "here", url: link(host) }];
|
|
94
|
+
|
|
95
|
+
const LABELS = { private: "on your network", cgnat: "on tailscale", public: "on the internet" } as const;
|
|
96
|
+
const found: { label: string; url: string; kind: keyof typeof LABELS }[] = [];
|
|
97
|
+
for (const [name, entries] of Object.entries(networkInterfaces())) {
|
|
98
|
+
if (VIRTUAL.test(name)) continue;
|
|
99
|
+
for (const entry of entries ?? []) {
|
|
100
|
+
// Link-local v6 needs a scope id to be usable, and nobody types those in.
|
|
101
|
+
if (entry.internal || entry.family !== "IPv4") continue;
|
|
102
|
+
const kind = classify(entry.address);
|
|
103
|
+
found.push({ label: LABELS[kind], url: link(entry.address), kind });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const order = { private: 0, cgnat: 1, public: 2 } as const;
|
|
107
|
+
found.sort((x, y) => order[x.kind] - order[y.kind]);
|
|
108
|
+
return [
|
|
109
|
+
{ label: "here", url: `http://localhost:${port}` },
|
|
110
|
+
...found.map(({ label, url }) => ({ label, url })),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The full link, key and all. */
|
|
115
|
+
export function shareLink(base: string, key: string | null): string {
|
|
116
|
+
return key === null ? base : `${base}/s/${key}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** How to run a command, so the tests never touch a real firewall. */
|
|
120
|
+
export interface Runner {
|
|
121
|
+
read(path: string): string | null;
|
|
122
|
+
run(command: string, args: string[]): { status: number | null; stdout: string };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Which firewall is in the way, if any. */
|
|
126
|
+
export type Firewall = "ufw" | "firewalld";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a firewall is running that would keep the port closed to other
|
|
130
|
+
* devices. Listening on 0.0.0.0 proves the socket is open on this machine and
|
|
131
|
+
* nothing more, so this is the difference between "it works" and "it works
|
|
132
|
+
* here".
|
|
133
|
+
*/
|
|
134
|
+
export function firewallInUse(io: Runner): Firewall | null {
|
|
135
|
+
if (process.platform !== "linux") return null;
|
|
136
|
+
|
|
137
|
+
// ufw keeps its state in a file, so asking needs no privileges.
|
|
138
|
+
const ufw = io.read("/etc/ufw/ufw.conf");
|
|
139
|
+
if (ufw && /^ENABLED=yes/im.test(ufw)) return "ufw";
|
|
140
|
+
|
|
141
|
+
const firewalld = io.run("systemctl", ["is-active", "firewalld"]);
|
|
142
|
+
if (firewalld.status === 0 && firewalld.stdout.trim() === "active") return "firewalld";
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The commands that open and close a port, for each firewall we know. */
|
|
147
|
+
export function portCommands(firewall: Firewall, port: number): { open: string[]; close: string[] } {
|
|
148
|
+
return firewall === "ufw"
|
|
149
|
+
? { open: ["ufw", "allow", `${port}/tcp`], close: ["ufw", "delete", "allow", `${port}/tcp`] }
|
|
150
|
+
: {
|
|
151
|
+
open: ["firewall-cmd", `--add-port=${port}/tcp`],
|
|
152
|
+
close: ["firewall-cmd", `--remove-port=${port}/tcp`],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Root runs it directly; anyone else goes through sudo, and only when sudo
|
|
158
|
+
* will not stop to ask. A server that hangs on an invisible password prompt is
|
|
159
|
+
* worse than one that tells you the command to run yourself.
|
|
160
|
+
*/
|
|
161
|
+
export function elevate(io: Runner, command: string[]): string[] | null {
|
|
162
|
+
const [head, ...rest] = command as [string, ...string[]];
|
|
163
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return [head, ...rest];
|
|
164
|
+
const canSudo = io.run("sudo", ["-n", "true"]);
|
|
165
|
+
return canSudo.status === 0 ? ["sudo", "-n", head, ...rest] : null;
|
|
166
|
+
}
|
package/src/sources.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a playlist comes from.
|
|
3
|
+
*
|
|
4
|
+
* A directory, a file, an .m3u, or a URL to any of those. ffmpeg reads a URL as
|
|
5
|
+
* happily as a path, so a remote track needs no special case once it is in the
|
|
6
|
+
* list; what needs care is telling the four apart, and telling an .m3u that
|
|
7
|
+
* lists tracks from an HLS playlist that *is* one track.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** http and https only. ffmpeg speaks more, but these are what a link is. */
|
|
11
|
+
export function isRemote(source: string): boolean {
|
|
12
|
+
return /^https?:\/\//i.test(source);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isPlaylistFile(source: string): boolean {
|
|
16
|
+
const path = isRemote(source) ? new URL(source).pathname : source;
|
|
17
|
+
return /\.(m3u|m3u8|pls)$/i.test(path);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An HLS playlist describes one stream in segments; an .m3u describes a list of
|
|
22
|
+
* things to play. Both are "m3u8" on disk, and the tags are the only honest way
|
|
23
|
+
* to tell them apart. Expanding an HLS playlist into a track per segment would
|
|
24
|
+
* turn one song into four hundred.
|
|
25
|
+
*/
|
|
26
|
+
export function isHls(text: string): boolean {
|
|
27
|
+
return /^#EXT-X-(?:STREAM-INF|TARGETDURATION|MEDIA-SEQUENCE|PLAYLIST-TYPE|ENDLIST)/im.test(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Entry {
|
|
31
|
+
/** A path or a URL, whichever the playlist gave us. */
|
|
32
|
+
source: string;
|
|
33
|
+
title: string;
|
|
34
|
+
/** Seconds, from #EXTINF. Zero when it did not say, and for live. */
|
|
35
|
+
duration: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolve a playlist line against the playlist's own location. */
|
|
39
|
+
export function resolveEntry(base: string, entry: string): string {
|
|
40
|
+
if (isRemote(entry)) return entry;
|
|
41
|
+
if (isRemote(base)) return new URL(entry, base).toString();
|
|
42
|
+
if (entry.startsWith("/")) return entry;
|
|
43
|
+
const dir = base.slice(0, Math.max(0, base.lastIndexOf("/")));
|
|
44
|
+
return dir ? `${dir}/${entry}` : entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse an .m3u or .m3u8. `#EXTINF:<seconds>,<title>` decorates the line after
|
|
49
|
+
* it; everything else beginning with # is a comment or a tag we do not need.
|
|
50
|
+
*/
|
|
51
|
+
export function parseM3u(text: string, base: string): Entry[] {
|
|
52
|
+
const out: Entry[] = [];
|
|
53
|
+
let duration = 0;
|
|
54
|
+
let title = "";
|
|
55
|
+
|
|
56
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
57
|
+
const line = raw.trim();
|
|
58
|
+
if (line === "") continue;
|
|
59
|
+
|
|
60
|
+
if (line.startsWith("#")) {
|
|
61
|
+
const info = /^#EXTINF:\s*(-?[\d.]+)\s*(?:,(.*))?$/i.exec(line);
|
|
62
|
+
if (info) {
|
|
63
|
+
const seconds = Number(info[1]);
|
|
64
|
+
// -1 is the conventional "unknown", which is also what live means.
|
|
65
|
+
duration = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
|
|
66
|
+
title = (info[2] ?? "").trim();
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const source = resolveEntry(base, line);
|
|
72
|
+
out.push({ source, duration, title: title || nameOf(source) });
|
|
73
|
+
duration = 0;
|
|
74
|
+
title = "";
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
|
|
80
|
+
export function parsePls(text: string, base: string): Entry[] {
|
|
81
|
+
const files = new Map<string, string>();
|
|
82
|
+
const titles = new Map<string, string>();
|
|
83
|
+
const lengths = new Map<string, number>();
|
|
84
|
+
|
|
85
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
86
|
+
const line = raw.trim();
|
|
87
|
+
const match = /^(File|Title|Length)(\d+)\s*=\s*(.*)$/i.exec(line);
|
|
88
|
+
if (!match) continue;
|
|
89
|
+
const [, kind, index, value] = match as unknown as [string, string, string, string];
|
|
90
|
+
if (/^file$/i.test(kind)) files.set(index, value);
|
|
91
|
+
else if (/^title$/i.test(kind)) titles.set(index, value);
|
|
92
|
+
else lengths.set(index, Number(value));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [...files.entries()]
|
|
96
|
+
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
|
97
|
+
.map(([index, file]) => {
|
|
98
|
+
const source = resolveEntry(base, file);
|
|
99
|
+
const seconds = lengths.get(index) ?? 0;
|
|
100
|
+
return {
|
|
101
|
+
source,
|
|
102
|
+
title: titles.get(index)?.trim() || nameOf(source),
|
|
103
|
+
duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The last useful part of a path or URL, for when nothing named the track. */
|
|
109
|
+
export function nameOf(source: string): string {
|
|
110
|
+
const remote = isRemote(source);
|
|
111
|
+
const path = remote ? new URL(source).pathname : source;
|
|
112
|
+
const last = path.split("/").filter(Boolean).pop() ?? source;
|
|
113
|
+
// Percent-decoding is a URL's business. A file on disk called
|
|
114
|
+
// `Some%20Song.mp3` is called exactly that, and renaming it in the display
|
|
115
|
+
// would be a lie about what is in the directory.
|
|
116
|
+
if (!remote) return last || source;
|
|
117
|
+
try {
|
|
118
|
+
return decodeURIComponent(last) || source;
|
|
119
|
+
} catch {
|
|
120
|
+
return last || source;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Formats a browser will play as-is. Anything else gets transcoded on the way
|
|
126
|
+
* out, which is the difference between a library that plays on a phone and one
|
|
127
|
+
* that plays on the machine it lives on.
|
|
128
|
+
*/
|
|
129
|
+
const WEB_READY = new Set([".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".webm", ".mp4", ".wav"]);
|
|
130
|
+
|
|
131
|
+
export function playsInBrowser(source: string): boolean {
|
|
132
|
+
if (isRemote(source)) return false;
|
|
133
|
+
const path = source.toLowerCase();
|
|
134
|
+
const dot = path.lastIndexOf(".");
|
|
135
|
+
return dot > 0 && WEB_READY.has(path.slice(dot));
|
|
136
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
nixamp installer for Windows.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
irm https://nixamp.com/install.ps1 | iex
|
|
7
|
+
|
|
8
|
+
Installs the app and the CLI under %LOCALAPPDATA%\nixamp. No administrator
|
|
9
|
+
rights, no registry, nothing outside your profile. The CLI runs on the Node
|
|
10
|
+
inside the app bundle, so no system Node is needed.
|
|
11
|
+
|
|
12
|
+
Updating is `nixamp update` and removing is `nixamp uninstall`, which runs a
|
|
13
|
+
script this installer leaves behind.
|
|
14
|
+
|
|
15
|
+
.PARAMETER CliOnly
|
|
16
|
+
Never install the app. The CLI then needs Node 24 or newer on PATH.
|
|
17
|
+
|
|
18
|
+
.PARAMETER Version
|
|
19
|
+
Install a specific release instead of the latest.
|
|
20
|
+
|
|
21
|
+
.PARAMETER Prefix
|
|
22
|
+
Install root. Defaults to $env:LOCALAPPDATA\nixamp.
|
|
23
|
+
|
|
24
|
+
.EXAMPLE
|
|
25
|
+
& ([scriptblock]::Create((irm https://nixamp.com/install.ps1))) -CliOnly
|
|
26
|
+
#>
|
|
27
|
+
[CmdletBinding()]
|
|
28
|
+
param(
|
|
29
|
+
[switch]$CliOnly,
|
|
30
|
+
[string]$Version = $env:NIXAMP_VERSION,
|
|
31
|
+
[string]$Prefix = $(if ($env:NIXAMP_PREFIX) { $env:NIXAMP_PREFIX } else { Join-Path $env:LOCALAPPDATA 'nixamp' })
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
$ErrorActionPreference = 'Stop'
|
|
35
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
36
|
+
|
|
37
|
+
$Repo = 'profullstack/nixamp'
|
|
38
|
+
$Site = if ($env:NIXAMP_SITE) { $env:NIXAMP_SITE } else { 'https://nixamp.com' }
|
|
39
|
+
|
|
40
|
+
function Fail($message) {
|
|
41
|
+
Write-Error "nixamp: $message"
|
|
42
|
+
exit 1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# arm64 Windows exists and Electron ships for it, so do not assume x64.
|
|
46
|
+
$Arch = switch ($env:PROCESSOR_ARCHITECTURE) {
|
|
47
|
+
'AMD64' { 'x64' }
|
|
48
|
+
'ARM64' { 'arm64' }
|
|
49
|
+
'x86' { Fail 'nixamp does not ship a 32-bit build.' }
|
|
50
|
+
default { 'x64' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (-not $Version) {
|
|
54
|
+
try {
|
|
55
|
+
$latest = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
|
|
56
|
+
$Version = $latest.tag_name -replace '^v', ''
|
|
57
|
+
} catch {
|
|
58
|
+
Fail "could not determine the latest version. Pass -Version, or see $Site"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$Base = if ($env:NIXAMP_RELEASE_BASE) {
|
|
63
|
+
$env:NIXAMP_RELEASE_BASE
|
|
64
|
+
} else {
|
|
65
|
+
"https://github.com/$Repo/releases/download/v$Version"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
$Bin = Join-Path $Prefix 'bin'
|
|
69
|
+
$Share = Join-Path $Prefix 'share'
|
|
70
|
+
|
|
71
|
+
Write-Host "nixamp $Version"
|
|
72
|
+
Write-Host " platform: windows-$Arch"
|
|
73
|
+
Write-Host " desktop: $(if ($CliOnly) { 'no' } else { 'yes' })"
|
|
74
|
+
Write-Host " prefix: $Prefix"
|
|
75
|
+
Write-Host ""
|
|
76
|
+
|
|
77
|
+
$work = Join-Path ([System.IO.Path]::GetTempPath()) ("nixamp-" + [guid]::NewGuid().ToString('N'))
|
|
78
|
+
New-Item -ItemType Directory -Force -Path $work, $Bin, $Share | Out-Null
|
|
79
|
+
|
|
80
|
+
$paths = New-Object System.Collections.Generic.List[string]
|
|
81
|
+
$paths.Add((Join-Path $Bin 'nixamp.cmd'))
|
|
82
|
+
$paths.Add($Share)
|
|
83
|
+
$method = 'cli-tarball'
|
|
84
|
+
$appDir = Join-Path $Share 'app'
|
|
85
|
+
$cliDir = $null
|
|
86
|
+
$runtime = $null
|
|
87
|
+
|
|
88
|
+
# --- the app ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
if (-not $CliOnly) {
|
|
91
|
+
$asset = "nixamp-$Version-win-$Arch.zip"
|
|
92
|
+
Write-Host 'Downloading the app...'
|
|
93
|
+
try {
|
|
94
|
+
Invoke-WebRequest "$Base/$asset" -OutFile (Join-Path $work 'app.zip')
|
|
95
|
+
if (Test-Path $appDir) { Remove-Item -Recurse -Force $appDir }
|
|
96
|
+
New-Item -ItemType Directory -Force -Path $appDir | Out-Null
|
|
97
|
+
Expand-Archive -Path (Join-Path $work 'app.zip') -DestinationPath $appDir -Force
|
|
98
|
+
$method = 'windows-app'
|
|
99
|
+
$cliDir = Join-Path $appDir 'resources\cli'
|
|
100
|
+
$runtime = Join-Path $appDir 'nixamp.exe'
|
|
101
|
+
|
|
102
|
+
# A Start menu shortcut, which is what the NSIS installer would give you,
|
|
103
|
+
# done without touching anything outside the profile.
|
|
104
|
+
$menu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\nixamp.lnk'
|
|
105
|
+
$shell = New-Object -ComObject WScript.Shell
|
|
106
|
+
$link = $shell.CreateShortcut($menu)
|
|
107
|
+
$link.TargetPath = $runtime
|
|
108
|
+
$link.WorkingDirectory = $appDir
|
|
109
|
+
$link.Description = "It really whips the terminal's ass."
|
|
110
|
+
$link.Save()
|
|
111
|
+
$paths.Add($menu)
|
|
112
|
+
} catch {
|
|
113
|
+
Write-Host " the app could not be installed ($($_.Exception.Message)); installing the CLI only."
|
|
114
|
+
$CliOnly = $true
|
|
115
|
+
$method = 'cli-tarball'
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# --- the CLI ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
if ($method -eq 'cli-tarball') {
|
|
122
|
+
# Pure JavaScript, so one bundle runs everywhere a Node does.
|
|
123
|
+
$asset = "nixamp-cli-$Version.tar.gz"
|
|
124
|
+
Write-Host 'Downloading the CLI...'
|
|
125
|
+
$tarball = Join-Path $work 'cli.tar.gz'
|
|
126
|
+
try {
|
|
127
|
+
Invoke-WebRequest "$Base/$asset" -OutFile $tarball
|
|
128
|
+
} catch {
|
|
129
|
+
Fail "could not download $Base/$asset"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
$cliDir = Join-Path $Share 'cli'
|
|
133
|
+
if (Test-Path $cliDir) { Remove-Item -Recurse -Force $cliDir }
|
|
134
|
+
New-Item -ItemType Directory -Force -Path $cliDir | Out-Null
|
|
135
|
+
# bsdtar has shipped in Windows since 1809 and reads gzip, so there is no
|
|
136
|
+
# third-party unpacker to ask for.
|
|
137
|
+
tar -xzf $tarball -C $cliDir --strip-components=1
|
|
138
|
+
if ($LASTEXITCODE -ne 0) { Fail 'could not unpack the CLI.' }
|
|
139
|
+
|
|
140
|
+
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
|
141
|
+
Write-Host ' note: no app was installed, so the CLI needs Node 24 or newer. It was not found.'
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# The shim, written here because only the installer knows which of the two
|
|
146
|
+
# runtimes this machine ended up with.
|
|
147
|
+
$entry = Join-Path $cliDir 'bin\nixamp.mjs'
|
|
148
|
+
$shim = Join-Path $Bin 'nixamp.cmd'
|
|
149
|
+
if ($runtime) {
|
|
150
|
+
@"
|
|
151
|
+
@echo off
|
|
152
|
+
rem nixamp. Runs on the Node inside the app, so no system Node is required.
|
|
153
|
+
rem Written by the installer; ``nixamp uninstall`` removes it.
|
|
154
|
+
set NIXAMP_HOME=$Share
|
|
155
|
+
set ELECTRON_RUN_AS_NODE=1
|
|
156
|
+
"$runtime" "$entry" %*
|
|
157
|
+
"@ | Set-Content -Path $shim -Encoding ASCII
|
|
158
|
+
} else {
|
|
159
|
+
@"
|
|
160
|
+
@echo off
|
|
161
|
+
rem nixamp. Written by the installer; ``nixamp uninstall`` removes it.
|
|
162
|
+
set NIXAMP_HOME=$Share
|
|
163
|
+
node "$entry" %*
|
|
164
|
+
"@ | Set-Content -Path $shim -Encoding ASCII
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# --- what was installed, and how to remove it ---------------------------------
|
|
168
|
+
|
|
169
|
+
$manifest = [ordered]@{
|
|
170
|
+
version = $Version
|
|
171
|
+
method = $method
|
|
172
|
+
installer = "$Site/install.ps1"
|
|
173
|
+
installedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
174
|
+
prefix = $Prefix
|
|
175
|
+
desktop = ($method -eq 'windows-app')
|
|
176
|
+
paths = $paths.ToArray()
|
|
177
|
+
}
|
|
178
|
+
$manifest | ConvertTo-Json | Set-Content -Path (Join-Path $Share 'manifest.json') -Encoding UTF8
|
|
179
|
+
|
|
180
|
+
$removals = ($paths | ForEach-Object { "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue '$_'" }) -join "`n"
|
|
181
|
+
@"
|
|
182
|
+
# Removes nixamp. Written by the installer, which knew exactly what it created.
|
|
183
|
+
# Your music is NOT touched.
|
|
184
|
+
$removals
|
|
185
|
+
Write-Host 'nixamp removed.'
|
|
186
|
+
"@ | Set-Content -Path (Join-Path $Share 'uninstall.ps1') -Encoding UTF8
|
|
187
|
+
|
|
188
|
+
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
|
|
189
|
+
|
|
190
|
+
# --- PATH ---------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
|
193
|
+
if ($userPath -notlike "*$Bin*") {
|
|
194
|
+
[Environment]::SetEnvironmentVariable('Path', "$userPath;$Bin", 'User')
|
|
195
|
+
Write-Host ""
|
|
196
|
+
Write-Host "Added $Bin to your PATH. Open a new terminal for it to take effect."
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
Write-Host ""
|
|
200
|
+
Write-Host "Installed nixamp $Version"
|
|
201
|
+
Write-Host " $(if ($method -eq 'windows-app') { 'app and CLI' } else { 'CLI only' })"
|
|
202
|
+
|
|
203
|
+
# ffmpeg decodes every track. Saying so now beats a confusing failure on first
|
|
204
|
+
# use, when the playlist loads and nothing comes out of the speakers.
|
|
205
|
+
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
|
206
|
+
Write-Host ""
|
|
207
|
+
Write-Host ' ffmpeg was not found, and nixamp decodes with ffmpeg.'
|
|
208
|
+
Write-Host ' winget install Gyan.FFmpeg'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
Write-Host ""
|
|
212
|
+
Write-Host 'Try: nixamp %USERPROFILE%\Music'
|
|
213
|
+
Write-Host 'Update with `nixamp update`, remove with `nixamp uninstall`.'
|
|
214
|
+
Write-Host "Docs: $Site"
|
package/web/dist/sw.js
CHANGED