// Name: Install app from dmg in Downloads /// Implementation notes: /// - dmg file name can be different from mounted volume name, e.g. UIBrowser302.dmg -> /Volumes/UI Browser 3.0.2.0 /// - dmg might contain License agreement that needs to be accepted, e.g. UIBrowser302.dmg /// - dmg might contain other files than just the app, e.g. Extras folder and README.rtf, see UIBrowser302.dmg import "@johnlindquist/kit" import fs, {statSync, unlinkSync} from "fs"; import {join} from "path"; import * as luxon from "luxon" import {execa} from "execa"; import {execSync} from "child_process" let downloadsDir = home("Downloads") // Feel free to change let dmgPaths = await globby("*.dmg", { cwd: downloadsDir }) let dmgObjs = dmgPaths.map(path => ({ fullPath: join(downloadsDir, path), baseName: path.split("/").pop()?.replace(".dmg", ""), createdAt: statSync(join(downloadsDir, path)).ctime.getTime(), sizeInMb: statSync(join(downloadsDir, path)).size / 1024 / 1024 })).sort((a, b) => b.createdAt - a.createdAt) if (dmgObjs.length === 0) { setPlaceholder("No DMG files found in Downloads directory") } else { let selectedDmgPath = await arg({ placeholder: "Which dmg?", choices: dmgObjs.map(dmg => ({ value: dmg.fullPath, name: dmg.baseName, description: `${luxon.DateTime.fromMillis(dmg.createdAt).toFormat('yyyy-MM-dd HH:mm')} • ${dmg.sizeInMb.toFixed(2)} MB` })) }) console.log(`Mounting ${selectedDmgPath}`) let volumeName = await attachDmg(selectedDmgPath) let mountPath = `/Volumes/${volumeName}`; console.log(`Mounted to ${mountPath}`) // Note: Globby did not work for me for mounted volumes let apps = fs.readdirSync(mountPath).filter(f => f.endsWith(".app")) if (apps.length === 0) { setPlaceholder("No apps found in the mounted volume") // TODO: Find a better way to do early returns/exits } else { let confirmed = await arg({ placeholder: `Found ${apps.length} apps: ${apps.join(", ")}, install?`, choices: ["yes", "no"] }) if (confirmed !== "yes") { notify("Aborted") process.exit(0) } for (let app of apps) { console.log(`Copying ${app} to /Applications folder`); await execa(`cp`, [ '-a', `${mountPath}/${app}`, '/Applications/' ]); } console.log(`Detaching ${mountPath}`) await detachDmg(mountPath) let confirmDeletion = await arg({ placeholder: `Delete ${selectedDmgPath}?`, choices: ["yes", "no"] }) if (confirmDeletion === "yes") { console.log(`Deleting ${selectedDmgPath}`) await trash(selectedDmgPath) } } } // Helpers // === async function attachDmg(dmgPath: string): Promise<string> { // https://superuser.com/questions/221136/bypass-a-licence-agreement-when-mounting-a-dmg-on-the-command-line let out = execSync(`yes | PAGER=cat hdiutil attach "${dmgPath}"`).toString() let lines = out.split("\n").reverse() // from the end, find line with volume name // /dev/disk6s2 Apple_HFS /Volumes/UI Browser 3.0.2.0 let lineWithVolume = lines.find(line => line.includes("/Volumes/")) if (!lineWithVolume) { throw new Error(`Failed to find volume name in output: ${out}`) } let volumeName = lineWithVolume.split(`/Volumes/`)[1] return volumeName } async function detachDmg(mountPoint: string) { await execa('hdiutil', ['detach', mountPoint]) }