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" ;
25import JSZip from "jszip" ;
36import path from "path" ;
47
@@ -15,25 +18,85 @@ export const relaunch = async () => {
1518
1619const 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+
0 commit comments