diff --git a/packages/core/src/persist.ts b/packages/core/src/persist.ts index 0384192f..106fa859 100644 --- a/packages/core/src/persist.ts +++ b/packages/core/src/persist.ts @@ -1,20 +1,49 @@ -import { join, dirname } from "node:path"; +import { resolve, join, dirname } from "node:path"; import { mkdirSync, renameSync, existsSync, copyFileSync, readdirSync } from "node:fs"; import Logger from "./util/logger"; +import * as darwin from "./util/darwin"; const logger = new Logger("core/persist"); export default function persist(asarPath: string) { try { - if (process.platform === "win32") { - persistWin32(asarPath); - } + hookUpdaterForPersistence(asarPath); } catch (e) { logger.error(`Failed to persist moonlight: ${e}`); } } -function persistWin32(asarPath: string) { +function postPersistSign(bundlePath: string) { + if (process.platform !== "darwin") { + logger.error("Ignoring call to postPersistSign because we're not on Darwin"); + return; + } + + logger.debug("Inferred bundle path:", bundlePath); + + if (darwin.verifySync(bundlePath, { verbosityLevel: 3 })) { + logger.warn("Bundle is currently passing code signing, no need to sign"); + return; + } else { + logger.debug("Bundle no longer passes code signing (this is expected)"); + } + + darwin.signSync(bundlePath, { + deep: true, + force: true, + // TODO: let this be configurable + identity: "moonlight", + verbosityLevel: 3 + }); + + if (darwin.verifySync(bundlePath, { verbosityLevel: 3 })) { + logger.info("Bundle signed succesfully!"); + } else { + logger.error("Bundle didn't pass code signing even after signing, the app might be broken now :("); + } +} + +function hookUpdaterForPersistence(asarPath: string) { const updaterModule = require(join(asarPath, "common", "updater")); const updater = updaterModule.Updater; @@ -42,6 +71,14 @@ function persistWin32(asarPath: string) { for (const file of readdirSync(currentAppDir)) { copyFileSync(join(currentAppDir, file), join(newAppDir, file)); } + + // on darwin, making changes disrupted the code signature on the app + // bundle, so we need to re-sign + if (process.platform === "darwin") { + // the asar is at Discord.app/Contents/Resources/app.asar + const inferredBundlePath = resolve(join(dirname(asarPath), "..", "..")); + postPersistSign(inferredBundlePath); + } } return realEmit.call(this, event, ...args); diff --git a/packages/core/src/util/darwin.ts b/packages/core/src/util/darwin.ts new file mode 100644 index 00000000..be71d8a0 --- /dev/null +++ b/packages/core/src/util/darwin.ts @@ -0,0 +1,86 @@ +// Helper functions that wrap shelling out to codesign(1). This is only +// relevant for Darwin (macOS). These are synchronous because they need to +// block the updater. + +import { spawnSync } from "node:child_process"; +import Logger from "./logger"; + +/** Flags that may be passed to `codesign(1)` regardless of action. */ +export interface SharedCodesignOptions { + verbosityLevel?: number; +} + +/** Flags that may be passed to `codesign(1)` when signing a bundle. */ +export interface SigningOptions extends SharedCodesignOptions { + /** + * Sign nested code items (such as Helper (Renderer).app, Helper (GPU).app, etc.) + * + * `--deep` is technically Bad (https://forums.developer.apple.com/forums/thread/129980), + * but it works for now. + */ + deep?: boolean; + + /** Steamrolls any existing code signature present in the bundle. */ + force?: boolean; + + /** + * The name of the signing identity to use. Signing identities are automatically queried from the user's keychains. + * + * `-` specifies ad-hoc signing, which does not involve an identity at all + * and is used to sign exactly one instance of code. + */ + identity: "-" | (string & {}); // "& {}" prevents TS from unifying the literal. +} + +const logger = new Logger("core/darwin"); + +function codesignSync(commandLineOptions: string[]) { + logger.debug("Invoking codesign with args:", commandLineOptions); + const result = spawnSync("/usr/bin/codesign", commandLineOptions, { stdio: "pipe" }); + + if (result.stdout) logger.debug("codesign stdout:", result.stdout); + if (result.stderr) logger.debug("codesign stderr:", result.stderr); + + if (result.signal == null && result.status === 0) { + logger.debug("codesign peacefully exited"); + } else { + const reason = result.status != null ? `code ${result.status}` : `signal ${result.signal}`; + throw new Error(`codesign exited with ${reason}`); + } +} + +function* generateSharedCommandLineOptions(options: SharedCodesignOptions): IterableIterator { + if (options.verbosityLevel) yield "-" + "v".repeat(options.verbosityLevel); +} + +export function signSync(bundlePath: string, options: SigningOptions): void { + // codesign -s [-v] [--deep] [--force] + function* cliOptions(): IterableIterator { + yield "-s"; + yield options.identity; + + yield* generateSharedCommandLineOptions(options); + if (options.deep) yield "--deep"; + if (options.force) yield "--force"; + + yield bundlePath; + } + + codesignSync(Array.from(cliOptions())); +} + +export function verifySync(bundlePath: string, options: SharedCodesignOptions = {}): boolean { + // codesign --verify [-v] + function* cliOptions(): IterableIterator { + yield "--verify"; + yield* generateSharedCommandLineOptions(options); + yield bundlePath; + } + + try { + codesignSync(Array.from(cliOptions())); + return true; + } catch { + return false; + } +}