| 1 | import fs from "node:fs"; |
| 2 | import os from "node:os"; |
| 3 | import path from "node:path"; |
| 4 | import crypto from "node:crypto"; |
| 5 | import { spawn, spawnSync } from "node:child_process"; |
| 6 | import { fileURLToPath } from "node:url"; |
| 7 | import { inflateRawSync } from "node:zlib"; |
| 8 | import { replaceMacBundle, verifyReleaseBundle } from "./install-macos.mjs"; |
| 9 | import { APP_VERSION, APP_NAME } from "../src/app-socket.mjs"; |
| 10 | import { stateDir } from "../src/registry.mjs"; |
| 11 | |
| 12 | const repository="https://github.com/Hmbown/codewhale-cu-plugin"; |
| 13 | const limit=256*1024*1024; |
| 14 | const updateResultPath=()=>path.join(stateDir(),"update-result.json"); |
| 15 | export function readUpdateResult() { |
| 16 | try { |
| 17 | if(fs.statSync(updateResultPath()).size>4096) return null; |
| 18 | const result=JSON.parse(fs.readFileSync(updateResultPath(),"utf8")); |
| 19 | if(typeof result.ok!=="boolean"||typeof result.message!=="string"||result.message.length>1000) return null; |
| 20 | return {available:false,message:result.message}; |
| 21 | } catch { return null; } |
| 22 | } |
| 23 | async function responseBytes(response, maximum) { |
| 24 | const chunks=[]; let size=0; |
| 25 | for await(const chunk of response.body) { size+=chunk.length; if(size>maximum) throw new Error("The update service exceeded its response size limit."); chunks.push(chunk); } |
| 26 | return Buffer.concat(chunks); |
| 27 | } |
| 28 | export function newerVersion(candidate,current) { |
| 29 | const parse=value=>/^\d+\.\d+\.\d+$/.test(value)?value.split(".").map(Number):null; |
| 30 | const a=parse(candidate),b=parse(current); if(!a||!b) return false; |
| 31 | for(let i=0;i<3;i++) { if(a[i]!==b[i]) return a[i]>b[i]; } return false; |
| 32 | } |
| 33 | export function releaseUpdate(release,current=APP_VERSION) { |
| 34 | const version=release?.tag_name?.replace(/^v/,""); |
| 35 | if(!version||release.draft||release.prerelease||!newerVersion(version,current)) return {available:false,message:`You have Computer Use ${current}. No newer stable installer is available.`}; |
| 36 | const name=`Codewhale-Computer-Use-${version}-macos-universal.zip`; |
| 37 | const asset=release.assets?.find(asset=>asset.name===name); |
| 38 | const url=`${repository}/releases/download/v${version}/${name}`; |
| 39 | if(!asset||asset.browser_download_url!==url||!/^sha256:[a-f0-9]{64}$/.test(asset.digest)||!Number.isSafeInteger(asset.size)||asset.size<=0||asset.size>limit) return {available:false,message:`Version ${version} has no verified macOS installer yet.`}; |
| 40 | return {available:true,version,url,sha256:asset.digest.slice(7),size:asset.size,message:`Computer Use ${version} is available. Install it to restart the helper; existing computer sessions will stop.`}; |
| 41 | } |
| 42 | export async function checkForUpdate() { |
| 43 | const response=await fetch("https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest",{redirect:"error",headers:{Accept:"application/vnd.github+json","X-GitHub-Api-Version":"2022-11-28"},signal:AbortSignal.timeout(10_000)}); |
| 44 | if(response.status===404) return {available:false,message:"No stable installer has been published yet. Your current app is unchanged."}; |
| 45 | if(!response.ok) throw new Error(`The update service is unavailable (${response.status}). Try again later.`); |
| 46 | return releaseUpdate(JSON.parse((await responseBytes(response,1024*1024)).toString("utf8"))); |
| 47 | } |
| 48 | |
| 49 | /** Inspect both ZIP headers before extraction: no links, traversal or bombs. */ |
| 50 | export function validateReleaseZip(bytes) { |
| 51 | const minimum=Math.max(0,bytes.length-65557); let end=-1; |
| 52 | for(let i=bytes.length-22;i>=minimum;i--) if(bytes.readUInt32LE(i)===0x06054b50&&i+22+bytes.readUInt16LE(i+20)===bytes.length) { end=i; break; } |
| 53 | if(end<0||bytes.readUInt16LE(end+4)||bytes.readUInt16LE(end+6)) throw new Error("Invalid update archive."); |
| 54 | const count=bytes.readUInt16LE(end+10); let position=bytes.readUInt32LE(end+16),total=0; |
| 55 | if(!count||count>2000||bytes.readUInt16LE(end+8)!==count||position+bytes.readUInt32LE(end+12)!==end) throw new Error("Invalid update archive index."); |
| 56 | const seen=new Set(); |
| 57 | for(let i=0;i<count;i++) { |
| 58 | if(position+46>end||bytes.readUInt32LE(position)!==0x02014b50) throw new Error("Invalid update entry."); |
| 59 | const flags=bytes.readUInt16LE(position+8),method=bytes.readUInt16LE(position+10),length=bytes.readUInt16LE(position+28),extra=bytes.readUInt16LE(position+30),comment=bytes.readUInt16LE(position+32); |
| 60 | const name=bytes.subarray(position+46,position+46+length).toString("utf8"); |
| 61 | const kind=(bytes.readUInt32LE(position+38)>>>16)&0xf000,offset=bytes.readUInt32LE(position+42),compressed=bytes.readUInt32LE(position+20); |
| 62 | const size=bytes.readUInt32LE(position+24); total+=size; |
| 63 | if(flags&1||![0,8].includes(method)||![0,0x4000,0x8000].includes(kind)||total>512*1024*1024||position+46+length+extra+comment>end) throw new Error("Unsupported update entry."); |
| 64 | if(!name.startsWith(`${APP_NAME}.app/`)||name.includes("\\")||name.includes(":")||name.includes("\0")||name.split("/").some(part=>part===".."||part===".")||seen.has(name)) throw new Error("Unsafe update path."); |
| 65 | seen.add(name); |
| 66 | if(offset+30>position||bytes.readUInt32LE(offset)!==0x04034b50) throw new Error("Invalid update file header."); |
| 67 | const localLength=bytes.readUInt16LE(offset+26),localExtra=bytes.readUInt16LE(offset+28); |
| 68 | if(offset+30+localLength+localExtra+compressed>bytes.readUInt32LE(end+16)||bytes.subarray(offset+30,offset+30+localLength).toString("utf8")!==name) throw new Error("Inconsistent update file header."); |
| 69 | if(bytes.readUInt16LE(offset+8)!==method||bytes.readUInt16LE(offset+6)!==flags||(!(flags&8)&&(bytes.readUInt32LE(offset+18)!==compressed||bytes.readUInt32LE(offset+22)!==size))) throw new Error("Inconsistent update sizes or compression."); |
| 70 | const start=offset+30+localLength+localExtra; |
| 71 | // Header sizes are untrusted. Bound actual expansion before ditto writes |
| 72 | // anything, including a compressed payload whose headers understate size. |
| 73 | const payload=bytes.subarray(start,start+compressed); |
| 74 | let expanded; |
| 75 | try { expanded=method===0?payload.length:inflateRawSync(payload,{maxOutputLength:Math.max(size,1)}).length; } |
| 76 | catch { throw new Error("Invalid or oversized compressed update entry."); } |
| 77 | if(expanded!==size) throw new Error("The update entry size did not match its contents."); |
| 78 | position+=46+length+extra+comment; |
| 79 | } |
| 80 | if(position!==end) throw new Error("Invalid update archive length."); |
| 81 | return count; |
| 82 | } |
| 83 | |
| 84 | export async function prepareUpdate(update) { |
| 85 | if(!update?.available) throw new Error("Check for an available update first."); |
| 86 | if(!newerVersion(update.version,APP_VERSION)||update.url!==`${repository}/releases/download/v${update.version}/Codewhale-Computer-Use-${update.version}-macos-universal.zip`||!Number.isSafeInteger(update.size)||update.size<=0||update.size>limit) throw new Error("The update identity is invalid."); |
| 87 | // Only GitHub's fixed release URL and its asset CDN can serve the bytes. |
| 88 | let url=update.url, response; |
| 89 | for(let redirects=0;redirects<4;redirects++) { |
| 90 | response=await fetch(url,{redirect:"manual",signal:AbortSignal.timeout(60_000)}); |
| 91 | if(![301,302,303,307,308].includes(response.status)) break; |
| 92 | const next=new URL(response.headers.get("location"),url); |
| 93 | if(next.protocol!=="https:"||!["github.com","release-assets.githubusercontent.com","objects.githubusercontent.com"].includes(next.hostname)) throw new Error("The update download redirected to an unexpected host."); |
| 94 | url=next.href; |
| 95 | } |
| 96 | if(!response?.ok) throw new Error("The update could not be downloaded. Your current app is unchanged."); |
| 97 | const bytes=await responseBytes(response,update.size); |
| 98 | if(bytes.length!==update.size||crypto.createHash("sha256").update(bytes).digest("hex")!==update.sha256) throw new Error("The update checksum did not match. Your current app is unchanged."); |
| 99 | validateReleaseZip(bytes); |
| 100 | const stage=fs.mkdtempSync(path.join(os.tmpdir(),"codewhale-cu-release-")); |
| 101 | try { |
| 102 | const archive=path.join(stage,"release.zip"); fs.writeFileSync(archive,bytes,{mode:0o600}); |
| 103 | const result=spawnSync("ditto",["-x","-k",archive,stage],{encoding:"utf8"}); |
| 104 | if(result.status!==0) throw new Error("The update could not be unpacked."); |
| 105 | const bundle=path.join(stage,`${APP_NAME}.app`); verifyReleaseBundle(bundle); |
| 106 | const version=spawnSync("/usr/libexec/PlistBuddy",["-c","Print :CFBundleShortVersionString",path.join(bundle,"Contents","Info.plist")],{encoding:"utf8"}); |
| 107 | if(version.status!==0||version.stdout.trim()!==update.version) throw new Error("The downloaded app has a different version."); |
| 108 | return {stage,bundle}; |
| 109 | } catch(error) { fs.rmSync(stage,{recursive:true,force:true}); throw error; } |
| 110 | } |
| 111 | |
| 112 | export async function restartWithUpdate(prepared,destination) { |
| 113 | const logDir=path.join(os.homedir(),"Library","Logs",APP_NAME); fs.mkdirSync(logDir,{recursive:true}); |
| 114 | const log=fs.openSync(path.join(logDir,"update.log"),"a",0o600); |
| 115 | const child=spawn(process.execPath,[fileURLToPath(import.meta.url),"--apply",prepared.bundle,destination,String(process.pid),String(process.ppid)],{detached:true,stdio:["ignore",log,log]}); |
| 116 | try { await new Promise((resolve,reject)=>{child.once("spawn",resolve);child.once("error",reject);}); child.unref(); } |
| 117 | finally { fs.closeSync(log); } |
| 118 | } |
| 119 | |
| 120 | if(process.argv[1]===fileURLToPath(import.meta.url)&&process.argv[2]==="--apply") { |
| 121 | const [source,destination,owner,launcher]=process.argv.slice(3); |
| 122 | let result; |
| 123 | try { |
| 124 | verifyReleaseBundle(source); |
| 125 | process.kill(Number(owner),"SIGTERM"); |
| 126 | for(let i=0;i<100;i++) { try { process.kill(Number(owner),0); } catch { break; } await new Promise(resolve=>setTimeout(resolve,100)); } |
| 127 | try { process.kill(Number(owner),0); throw new Error("The running helper did not stop; update cancelled."); } catch(error) { if(error.code!=="ESRCH") throw error; } |
| 128 | // LaunchServices must not send Reopen to the retiring menu-bar process. |
| 129 | for(let i=0;i<100;i++) { try { process.kill(Number(launcher),0); } catch { break; } await new Promise(resolve=>setTimeout(resolve,100)); } |
| 130 | try { process.kill(Number(launcher),0); throw new Error("The menu-bar app did not exit; update cancelled."); } catch(error) { if(error.code!=="ESRCH") throw error; } |
| 131 | const receipt=replaceMacBundle(source,destination,{verify:verifyReleaseBundle}); |
| 132 | const installed=spawnSync("/usr/libexec/PlistBuddy",["-c","Print :CFBundleShortVersionString",path.join(destination,"Contents","Info.plist")],{encoding:"utf8"}); |
| 133 | if(installed.status!==0) throw new Error("The installed version could not be read."); |
| 134 | console.log(JSON.stringify({version:installed.stdout.trim(),...receipt,installedAt:new Date().toISOString()})); |
| 135 | result={ok:true,message:`Updated to ${installed.stdout.trim()}. Choose Allow new sessions when you are ready.`}; |
| 136 | } catch(error) { |
| 137 | console.error(error.message); process.exitCode=1; |
| 138 | result={ok:false,message:`The update could not be completed: ${String(error.message).slice(0,600)} Computer sessions remain stopped.`}; |
| 139 | } finally { |
| 140 | // The apply process outlives the menu app. Carry its result into the next |
| 141 | // launch so a failed install is visible without hunting for update.log. |
| 142 | try { |
| 143 | fs.mkdirSync(stateDir(),{recursive:true}); |
| 144 | const temporary=`${updateResultPath()}.${process.pid}.tmp`; |
| 145 | fs.writeFileSync(temporary,JSON.stringify({...result,completedAt:new Date().toISOString()})+"\n",{mode:0o600}); |
| 146 | fs.renameSync(temporary,updateResultPath()); |
| 147 | } catch(error) { console.error(`Could not save the update result: ${error.message}`); } |
| 148 | if(destination&&fs.existsSync(destination)) spawnSync("open",["-g","-a",destination]); |
| 149 | } |
| 150 | } |
| 151 |