Skip to content

Commit 2ed80da

Browse files
authored
Merge pull request #133 from Brskt/enhance-linux-update
Add Linux elevated update via pkexec/kdesudo
2 parents bc50621 + 1f4a672 commit 2ed80da

5 files changed

Lines changed: 140 additions & 27 deletions

File tree

native/injector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ const ProxiedBrowserWindow = new Proxy(electron.BrowserWindow, {
217217

218218
const window = new target(options);
219219

220-
globalThis.luna.sendToRender = window.webContents.send;
220+
globalThis.luna.sendToRender = window.webContents.send.bind(window.webContents);
221221

222222
// Linux (tidal-hifi): Handle OAuth login in a popup window
223223
if (platformIsLinux) {

native/update.ts

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { mkdir, readdir, rm, unlink, writeFile } from "fs/promises";
1+
import { access, mkdir, rm, unlink, writeFile } from "fs/promises";
2+
import { constants } from "fs";
3+
import { execFile } from "child_process";
4+
import { tmpdir } from "os";
25
import JSZip from "jszip";
36
import path from "path";
47

@@ -15,25 +18,85 @@ export const relaunch = async () => {
1518

1619
const appFolder = path.join(process.resourcesPath, "app");
1720

18-
export const update = async (version: string) => {
21+
export const needsElevation = async (): Promise<boolean> => {
22+
if (process.platform !== "linux") return false;
23+
try {
24+
await access(appFolder, constants.W_OK);
25+
return false;
26+
} catch {
27+
return true;
28+
}
29+
};
30+
31+
32+
const validateAppFolder = (folder: string) => {
33+
const resolved = path.resolve(folder);
34+
// Must be an absolute path ending with /resources/app inside a known app directory
35+
if (!resolved.endsWith(path.join("resources", "app"))) {
36+
throw new Error(`[UPDATER] Refusing elevated operation: unexpected app folder path "${resolved}"`);
37+
}
38+
// Must have at least 3 segments (e.g. /opt/tidal-hifi/resources/app)
39+
const segments = resolved.split(path.sep).filter(Boolean);
40+
if (segments.length < 3) {
41+
throw new Error(`[UPDATER] Refusing elevated operation: path too shallow "${resolved}"`);
42+
}
43+
};
44+
45+
const runElevated = (tool: string, args: string[]) =>
46+
new Promise<void>((resolve, reject) => {
47+
const child = execFile(tool, args);
48+
child.on("close", (code) => {
49+
if (code === 0) return resolve();
50+
if (code === 126) return reject(new Error("ELEVATION_CANCELLED"));
51+
reject(new Error(`${tool} exited with code ${code}`));
52+
});
53+
child.on("error", reject);
54+
});
55+
56+
const elevationTools = ["pkexec", "kdesudo"] as const;
57+
58+
const elevatedUpdate = async (zipBuffer: Buffer) => {
59+
validateAppFolder(appFolder);
60+
const tmpZip = path.join(tmpdir(), `luna-update-${Date.now()}.zip`);
61+
try {
62+
await writeFile(tmpZip, zipBuffer);
63+
const cmd = `rm -rf "${appFolder}" && mkdir -p "${appFolder}" && unzip -o "${tmpZip}" -d "${appFolder}"`;
64+
65+
for (const tool of elevationTools) {
66+
try {
67+
await runElevated(tool, tool === "kdesudo" ? ["-c", cmd] : ["sh", "-c", cmd]);
68+
return;
69+
} catch (err: any) {
70+
if (err.message === "ELEVATION_CANCELLED") throw err;
71+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
72+
}
73+
}
74+
throw new Error("NO_ELEVATION_TOOL");
75+
} finally {
76+
await unlink(tmpZip).catch(() => {});
77+
}
78+
};
79+
80+
let pendingZipBuffer: Buffer | null = null;
81+
82+
export const update = async (version: string): Promise<string> => {
1983
const zipUrl = `https://github.com/Inrixia/TidaLuna/releases/download/${version}/luna.zip`;
2084
const res = await fetch(zipUrl);
2185
if (!res.ok) throw new Error(`Failed to download ${zipUrl}\n${res.statusText}`);
2286

23-
// Ensure clean start
87+
const zipBuffer = Buffer.from(await res.arrayBuffer());
2488

25-
console.log(`[UPDATER] == Downloaded: ${zipUrl}`);
89+
if (process.platform === "linux" && (await needsElevation())) {
90+
pendingZipBuffer = zipBuffer;
91+
return "elevation_required";
92+
}
2693

2794
// Load zip purely from buffer (no internal fs usage by the library)
28-
const zip = await JSZip.loadAsync(Buffer.from(await res.arrayBuffer()));
29-
30-
console.log("[UPDATER] == Loaded zip into memory");
95+
const zip = await JSZip.loadAsync(zipBuffer);
3196

32-
await clearAppFolder();
97+
await rm(appFolder, { recursive: true, force: true });
3398
await mkdir(appFolder, { recursive: true });
3499

35-
console.log("[UPDATER] == Cleared app folder");
36-
37100
// Manually write files to disk
38101
const entries = Object.keys(zip.files);
39102
for (const filename of entries) {
@@ -58,21 +121,16 @@ export const update = async (version: string) => {
58121
}
59122
}
60123

61-
console.log("[UPDATER] == Extraction complete");
62-
63-
await relaunch();
124+
return "done";
64125
};
65126

66-
const clearAppFolder = async () => {
67-
// Check if folder exists before reading to avoid crashing on fresh installs
127+
export const runElevatedInstall = async (): Promise<void> => {
128+
if (!pendingZipBuffer) throw new Error("No pending update to install");
129+
68130
try {
69-
const entries = await readdir(appFolder, { withFileTypes: true });
70-
for (const entry of entries) {
71-
const fullPath = path.join(appFolder, entry.name);
72-
if (entry.isDirectory()) await rm(fullPath, { recursive: true, force: true });
73-
else await unlink(fullPath);
74-
}
75-
} catch (error: any) {
76-
if (error.code !== "ENOENT") throw error;
131+
await elevatedUpdate(pendingZipBuffer);
132+
} finally {
133+
pendingZipBuffer = null;
77134
}
78135
};
136+

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "luna",
3-
"version": "1.10.0-beta",
3+
"version": "1.11.0-beta",
44
"description": "A client mod for the Tidal music app for plugins",
55
"author": {
66
"name": "Inrixia",

plugins/lib.native/src/native.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export const pkg = luna.pkg;
22
export const update = luna.update;
33
export const relaunch = luna.relaunch;
4+
export const needsElevation = luna.needsElevation;
5+
export const runElevatedInstall = luna.runElevatedInstall;
46
export const sendToRender = luna.sendToRender;
57

68
// Electron

plugins/ui/src/SettingsPage/SettingsTab/LunaClientUpdate.tsx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import React from "react";
44
import { components } from "@octokit/openapi-types";
55
type GitHubRelease = components["schemas"]["release"];
66

7+
import Dialog from "@mui/material/Dialog";
8+
import DialogContent from "@mui/material/DialogContent";
9+
import DialogContentText from "@mui/material/DialogContentText";
10+
import DialogTitle from "@mui/material/DialogTitle";
711
import MenuItem from "@mui/material/MenuItem";
812
import Select from "@mui/material/Select";
913

10-
import { pkg, relaunch, update } from "plugins/lib.native/src/index.native";
14+
import { pkg, relaunch, update, needsElevation, runElevatedInstall } from "plugins/lib.native/src/index.native";
1115

1216
export const version = (await pkg()).version;
1317

@@ -20,6 +24,7 @@ export const LunaClientUpdate = React.memo(() => {
2024
const confirm = useConfirm();
2125
const [releases, setReleases] = React.useState<GitHubRelease[]>([]);
2226
const [loading, setLoading] = React.useState(false);
27+
const [busy, setBusy] = React.useState<"updating" | "resetting" | null>(null);
2328
const [selectedRelease, setSelectedRelease] = React.useState<string>(version!);
2429

2530
const updateReleases = async () => {
@@ -51,6 +56,12 @@ export const LunaClientUpdate = React.memo(() => {
5156
alignItems="center"
5257
pb={4}
5358
>
59+
<Dialog open={!!busy}>
60+
<DialogTitle>Operation in progress</DialogTitle>
61+
<DialogContent>
62+
<DialogContentText>Please do not close the application. It will restart automatically.</DialogContentText>
63+
</DialogContent>
64+
</Dialog>
5465
<Select
5566
fullWidth
5667
sx={{ flex: 1, height: 48 }}
@@ -62,19 +73,60 @@ export const LunaClientUpdate = React.memo(() => {
6273
/>
6374
<LunaButton
6475
sx={{ height: 48 }}
76+
disabled={!!busy}
6577
children={action}
6678
title={desc}
6779
onClick={async () => {
6880
const result = await confirm({ title: action, description: desc, confirmationText: action });
6981
if (!result.confirmed) return;
7082
const releaseUrl = releases.find((r) => r.tag_name === selectedRelease)?.assets[0].browser_download_url;
7183
if (releaseUrl === undefined) throw new Error("Release URL not found");
72-
await update(selectedRelease);
84+
85+
// On Linux, warn the user if elevation is needed
86+
if (__platform === "linux" && (await needsElevation())) {
87+
const elevationResult = await confirm({
88+
title: "Administrator privileges required",
89+
description:
90+
"TidaLuna does not have write access to the installation directory. " +
91+
"Your password will be requested to proceed with the update.",
92+
confirmationText: "Continue",
93+
cancellationText: "Cancel",
94+
});
95+
if (!elevationResult.confirmed) return;
96+
}
97+
98+
const updateResult = await update(selectedRelease);
99+
100+
if (updateResult === "elevation_required") {
101+
setBusy("updating");
102+
try {
103+
await runElevatedInstall();
104+
} catch (err: any) {
105+
setBusy(null);
106+
if (err.message?.includes("ELEVATION_CANCELLED")) return;
107+
if (err.message?.includes("NO_ELEVATION_TOOL")) {
108+
await confirm({
109+
title: "Elevation failed",
110+
description:
111+
"Neither pkexec nor kdesudo were found on your system. " +
112+
"Please perform this operation manually.",
113+
hideCancelButton: true,
114+
});
115+
return;
116+
}
117+
throw err;
118+
}
119+
}
120+
121+
setBusy("updating");
122+
await new Promise((resolve) => setTimeout(resolve, 2000));
123+
await relaunch();
73124
}}
74125
/>
75126
<LunaButton
76127
sx={{ height: 48, marginLeft: 2 }}
77128
color="error"
129+
disabled={!!busy}
78130
children={"Factory Reset"}
79131
title={"Warning! This will reset luna to a clean install with no plugins."}
80132
onClick={async () => {
@@ -85,6 +137,7 @@ export const LunaClientUpdate = React.memo(() => {
85137
});
86138
if (!ok.confirmed) return;
87139

140+
setBusy("resetting");
88141
for (const db of await indexedDB.databases()) {
89142
// Dont delete the tidal localforage db as it will reset the tidal app
90143
// Deleting other _TIDAL indexedDB databases is ok

0 commit comments

Comments
 (0)