@@ -109,10 +109,25 @@ function fetchText(url, timeoutMs = 15_000) {
109109 } ) ;
110110}
111111
112- function downloadFile ( url , outPath , timeoutMs = 30 * 60_000 ) {
112+ function normalizeDownloadProgress ( downloadedBytes , totalBytes ) {
113+ const downloaded = Math . max ( 0 , Number ( downloadedBytes ) || 0 ) ;
114+ const total = Math . max ( 0 , Number ( totalBytes ) || 0 ) ;
115+ const percent = total > 0 ? Math . min ( 100 , Math . max ( 0 , Math . round ( ( downloaded / total ) * 100 ) ) ) : 0 ;
116+ return {
117+ received_bytes : downloaded ,
118+ total_bytes : total ,
119+ percent,
120+ } ;
121+ }
122+
123+ function downloadFile ( url , outPath , timeoutMs = 30 * 60_000 , onProgress = null ) {
113124 return new Promise ( ( resolve , reject ) => {
114125 const requestUrl = new URL ( url ) ;
115126 const transport = requestUrl . protocol === "http:" ? http : https ;
127+ const emitProgress =
128+ typeof onProgress === "function"
129+ ? ( downloadedBytes , totalBytes ) => onProgress ( normalizeDownloadProgress ( downloadedBytes , totalBytes ) )
130+ : ( ) => { } ;
116131 const request = transport . get (
117132 requestUrl ,
118133 {
@@ -121,7 +136,7 @@ function downloadFile(url, outPath, timeoutMs = 30 * 60_000) {
121136 ( response ) => {
122137 if ( response . statusCode && response . statusCode >= 300 && response . statusCode < 400 && response . headers . location ) {
123138 response . resume ( ) ;
124- downloadFile ( new URL ( response . headers . location , requestUrl ) . toString ( ) , outPath , timeoutMs )
139+ downloadFile ( new URL ( response . headers . location , requestUrl ) . toString ( ) , outPath , timeoutMs , onProgress )
125140 . then ( resolve )
126141 . catch ( reject ) ;
127142 return ;
@@ -131,10 +146,21 @@ function downloadFile(url, outPath, timeoutMs = 30 * 60_000) {
131146 reject ( new Error ( `HTTP ${ response . statusCode || 0 } downloading ${ url } ` ) ) ;
132147 return ;
133148 }
149+ const totalBytes = Number . parseInt ( String ( response . headers [ "content-length" ] || "" ) , 10 ) || 0 ;
150+ let downloadedBytes = 0 ;
151+ emitProgress ( 0 , totalBytes ) ;
152+ response . on ( "data" , ( chunk ) => {
153+ downloadedBytes += chunk . length ;
154+ emitProgress ( downloadedBytes , totalBytes ) ;
155+ } ) ;
156+ response . on ( "error" , reject ) ;
134157 const file = fs . createWriteStream ( outPath ) ;
135158 response . pipe ( file ) ;
136159 file . on ( "finish" , ( ) => {
137- file . close ( ( ) => resolve ( outPath ) ) ;
160+ file . close ( ( ) => {
161+ emitProgress ( totalBytes > 0 ? totalBytes : downloadedBytes , totalBytes || downloadedBytes ) ;
162+ resolve ( outPath ) ;
163+ } ) ;
138164 } ) ;
139165 file . on ( "error" , ( error ) => {
140166 file . close ( ( ) => {
@@ -242,6 +268,98 @@ function installerLaunchArgs(installDir = resolveDesktopInstallDir()) {
242268 return [ ] ;
243269}
244270
271+ function macOSInstallScriptContent ( ) {
272+ return `#!/bin/sh
273+ set -u
274+
275+ DMG_PATH="$1"
276+ TARGET_APP="$2"
277+ PARENT_PID="$3"
278+ LOG_PATH="$4"
279+
280+ log() {
281+ printf '%s %s\\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_PATH"
282+ }
283+
284+ fallback_open_dmg() {
285+ log "Falling back to opening DMG"
286+ open "$DMG_PATH" >> "$LOG_PATH" 2>&1 || true
287+ }
288+
289+ while kill -0 "$PARENT_PID" 2>/dev/null; do
290+ sleep 0.2
291+ done
292+
293+ MOUNT_DIR="$(mktemp -d "/tmp/clovapi-desktop-dmg.XXXXXX")"
294+ cleanup() {
295+ hdiutil detach "$MOUNT_DIR" -quiet >> "$LOG_PATH" 2>&1 || true
296+ rmdir "$MOUNT_DIR" >> "$LOG_PATH" 2>&1 || true
297+ }
298+ trap cleanup EXIT
299+
300+ log "Attaching DMG: $DMG_PATH"
301+ if ! hdiutil attach "$DMG_PATH" -nobrowse -quiet -mountpoint "$MOUNT_DIR" >> "$LOG_PATH" 2>&1; then
302+ fallback_open_dmg
303+ exit 1
304+ fi
305+
306+ SOURCE_APP="$(find "$MOUNT_DIR" -maxdepth 1 -type d -name "*.app" | head -n 1)"
307+ if [ -z "$SOURCE_APP" ]; then
308+ log "No .app bundle found in DMG"
309+ fallback_open_dmg
310+ exit 1
311+ fi
312+
313+ TARGET_DIR="$(dirname "$TARGET_APP")"
314+ TARGET_NAME="$(basename "$TARGET_APP")"
315+ STAGING_APP="$TARGET_DIR/.$TARGET_NAME.updating.$$"
316+
317+ log "Copying $SOURCE_APP to staging path $STAGING_APP"
318+ rm -rf "$STAGING_APP" >> "$LOG_PATH" 2>&1 || true
319+ if ! ditto "$SOURCE_APP" "$STAGING_APP" >> "$LOG_PATH" 2>&1; then
320+ rm -rf "$STAGING_APP" >> "$LOG_PATH" 2>&1 || true
321+ fallback_open_dmg
322+ exit 1
323+ fi
324+
325+ log "Replacing installed app at $TARGET_APP"
326+ if ! rm -rf "$TARGET_APP" >> "$LOG_PATH" 2>&1; then
327+ rm -rf "$STAGING_APP" >> "$LOG_PATH" 2>&1 || true
328+ fallback_open_dmg
329+ exit 1
330+ fi
331+ if ! mv "$STAGING_APP" "$TARGET_APP" >> "$LOG_PATH" 2>&1; then
332+ fallback_open_dmg
333+ exit 1
334+ fi
335+
336+ xattr -dr com.apple.quarantine "$TARGET_APP" >> "$LOG_PATH" 2>&1 || true
337+ log "Opening updated app: $TARGET_APP"
338+ open "$TARGET_APP" >> "$LOG_PATH" 2>&1 || true
339+ ` ;
340+ }
341+
342+ function launchMacOSInstaller ( installerPath ) {
343+ const targetApp = resolveDesktopInstallDir ( ) ;
344+ if ( ! targetApp || ! targetApp . endsWith ( ".app" ) ) {
345+ spawn ( "open" , [ installerPath ] , {
346+ detached : true ,
347+ stdio : "ignore" ,
348+ } ) . unref ( ) ;
349+ return { mode : "open-dmg" } ;
350+ }
351+
352+ const workDir = path . dirname ( installerPath ) ;
353+ const scriptPath = path . join ( workDir , "install-macos.sh" ) ;
354+ const logPath = path . join ( workDir , "install-macos.log" ) ;
355+ fs . writeFileSync ( scriptPath , macOSInstallScriptContent ( ) , { mode : 0o700 } ) ;
356+ spawn ( "sh" , [ scriptPath , installerPath , targetApp , String ( process . pid ) , logPath ] , {
357+ detached : true ,
358+ stdio : "ignore" ,
359+ } ) . unref ( ) ;
360+ return { mode : "auto-install" , target_app : targetApp , log_path : logPath } ;
361+ }
362+
245363function launchInstaller ( installerPath ) {
246364 if ( process . platform === "win32" ) {
247365 spawn ( installerPath , installerLaunchArgs ( ) , {
@@ -252,31 +370,28 @@ function launchInstaller(installerPath) {
252370 return ;
253371 }
254372 if ( process . platform === "darwin" ) {
255- spawn ( "open" , [ installerPath ] , {
256- detached : true ,
257- stdio : "ignore" ,
258- } ) . unref ( ) ;
259- return ;
373+ return launchMacOSInstaller ( installerPath ) ;
260374 }
261375 throw new Error ( `Desktop updates are not supported on ${ process . platform } .` ) ;
262376}
263377
264- async function downloadAndLaunchDesktopUpdate ( ) {
378+ async function downloadAndLaunchDesktopUpdate ( options = { } ) {
379+ const onProgress = typeof options ?. onProgress === "function" ? options . onProgress : null ;
265380 const latestTag = await fetchLatestDesktopVersion ( ) ;
266381 const fileName = installerFileName ( ) ;
267382 const url = installerDownloadUrl ( latestTag ) ;
268383 const tmpDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "clovapi-desktop-update-" ) ) ;
269384 const installerPath = path . join ( tmpDir , fileName ) ;
270- await downloadFile ( url , installerPath ) ;
385+ await downloadFile ( url , installerPath , undefined , onProgress ) ;
271386 let verified = false ;
272387 try {
273388 verified = await verifyInstallerChecksum ( installerPath , latestTag ) ;
274389 } catch ( error ) {
275390 fs . rm ( tmpDir , { recursive : true , force : true } , ( ) => { } ) ;
276391 throw error ;
277392 }
278- launchInstaller ( installerPath ) ;
279- return { ok : true , path : installerPath , url, latest_tag : latestTag , checksum_verified : verified } ;
393+ const launch = launchInstaller ( installerPath ) || { } ;
394+ return { ok : true , path : installerPath , url, latest_tag : latestTag , checksum_verified : verified , launch } ;
280395}
281396
282397module . exports = {
@@ -290,7 +405,10 @@ module.exports = {
290405 verifyInstallerChecksum,
291406 fetchLatestDesktopVersion,
292407 checkDesktopUpdate,
408+ downloadFile,
293409 downloadAndLaunchDesktopUpdate,
410+ normalizeDownloadProgress,
294411 resolveDesktopInstallDir,
295412 installerLaunchArgs,
413+ macOSInstallScriptContent,
296414} ;
0 commit comments