diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9f04121..020cc823 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,3 +54,78 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: npm run publish --workspace=@diffusionstudio/desktop + + publish-linux: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Check versions match tag + run: | + TAG="${GITHUB_REF_NAME#v}" + for PKG in package.json apps/desktop/package.json apps/cli/package.json apps/web/package.json; do + V="$(node -p "require('./$PKG').version")" + if [ "$V" != "$TAG" ]; then + echo "Version mismatch in $PKG: $V, tag is $TAG" + exit 1 + fi + done + + - run: npm ci + + - name: Provide client env for web build + run: cp apps/web/.env.example apps/web/.env + + - name: Install packaging tools + run: sudo apt-get update && sudo apt-get install -y zip dpkg-dev fakeroot rpm squashfs-tools + + - name: Build and publish draft release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run publish --workspace=@diffusionstudio/desktop + + # Arch has no Electron Forge maker, so this job runs the makepkg script in an + # Arch container. It attaches its artifact to the same draft release the + # other two jobs publish to, and carries no Apple secrets either. + publish-arch: + runs-on: ubuntu-latest + container: archlinux:latest + permissions: + contents: write + steps: + # The image is minimal: actions/checkout needs git, and makepkg needs + # base-devel. `sudo` is here because makepkg refuses to run as root, + # which is what a container job starts as. + - name: Install build tools + run: | + pacman -Sy --noconfirm archlinux-keyring + pacman -Syu --noconfirm base-devel git sudo nodejs npm + + - uses: actions/checkout@v4 + + - run: npm ci + + - name: Provide client env for web build + run: cp apps/web/.env.example apps/web/.env + + - name: Build the Arch package as an unprivileged user + run: | + useradd -m builder + chown -R builder . + sudo -u builder npm run make:arch --workspace=@diffusionstudio/desktop + + - name: Attach the package to the draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pacman -S --noconfirm github-cli + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 \ + || gh release create "$GITHUB_REF_NAME" --draft --title "$GITHUB_REF_NAME" --generate-notes + gh release upload "$GITHUB_REF_NAME" apps/desktop/out/arch/*.pkg.tar.zst --clobber diff --git a/README.md b/README.md index ae64b883..05034bff 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,12 @@ npm run symlink:create --workspace=@diffusionstudio/cli The link points at the CLI build, which `npm run dev:desktop` refreshes on every start, so the linked `dapi` always runs the latest code. +`npm run make` builds the artifacts for the host platform: a ZIP and a DMG on macOS, and a ZIP, a `.deb`, an `.rpm` and an AppImage on Linux. Each Linux maker needs its own tool on the build host — `zip` for the ZIP, `dpkg-dev` and `fakeroot` for the deb, `rpm` for the rpm, `squashfs-tools` for the AppImage — and fails if it is missing, so install the ones you want to build. + +Arch Linux has no Electron Forge maker, so its package is a script: `npm run make:arch --workspace=@diffusionstudio/desktop` writes `apps/desktop/out/arch/*.pkg.tar.zst` and needs `base-devel` (makepkg, which refuses to run as root). Every format installs the one desktop entry in [packaging/linux](packaging/linux). + +On a Wayland session the app runs through XWayland, which is what Chromium picks by default; native Wayland (fractional scaling, no XWayland blur) is available with `ELECTRON_OZONE_PLATFORM_HINT=auto`, though it renders incorrectly on some drivers. + Before sending a PR: ```sh diff --git a/apps/cli/src/fonts.ts b/apps/cli/src/fonts.ts index d2de7964..9d4cc5c1 100644 --- a/apps/cli/src/fonts.ts +++ b/apps/cli/src/fonts.ts @@ -63,17 +63,7 @@ function run() { } `; -export type ListLocalFontsOptions = { - familyPattern?: string; - weights?: string[]; - style?: "normal" | "italic"; - limit?: number; -}; - -export function listLocalFonts(options: ListLocalFontsOptions = {}): FontFamily[] { - if (platform() !== "darwin") { - throw new Error("fonts is only supported on macOS."); - } +function listDarwinFonts(): FontFamily[] { const result = spawnSync("osascript", ["-l", "JavaScript", "-e", LIST_FONTS_JXA], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, @@ -81,8 +71,123 @@ export function listLocalFonts(options: ListLocalFontsOptions = {}): FontFamily[ if (result.status !== 0) { throw new Error(result.stderr.trim() || "Failed to enumerate fonts."); } + return JSON.parse(result.stdout.trim()) as FontFamily[]; +} + +// One line per font file, so a family arrives spread over many lines and the +// same variant repeats whenever it ships in several formats. +const FC_LIST_FORMAT = "%{family}\\t%{style[0]}\\t%{weight}\\t%{slant}\\t%{postscriptname}\\n"; + +// fontconfig's weight axis is its own scale, not CSS's: these are its named +// steps paired with the CSS weight each stands for. Anything between two +// steps is interpolated, so an unnamed intermediate weight still lands on a +// sensible value instead of being dropped. +const FC_WEIGHTS: readonly (readonly [fc: number, css: number])[] = [ + [0, 100], // thin + [40, 200], // extralight + [50, 300], // light + [75, 400], // book + [80, 400], // regular + [100, 500], // medium + [180, 600], // demibold + [200, 700], // bold + [205, 800], // extrabold + [210, 900], // black +]; + +function fcWeightToCss(weight: number): string { + let css = 900; + let previous: readonly [number, number] | undefined; + for (const step of FC_WEIGHTS) { + const [fc, value] = step; + if (weight <= fc) { + css = previous ? previous[1] + ((weight - previous[0]) / (fc - previous[0])) * (value - previous[1]) : value; + break; + } + previous = step; + } + return String(Math.round(css / 100) * 100); +} - const all = JSON.parse(result.stdout.trim()) as FontFamily[]; +function listFontconfigFonts(): FontFamily[] { + const result = spawnSync("fc-list", ["--format", FC_LIST_FORMAT], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + if (result.error) { + throw new Error("Listing fonts needs fontconfig: `fc-list` could not be run. Install the fontconfig package."); + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "fontconfig (`fc-list`) failed to enumerate fonts."); + } + + // Keyed by family, then by weight+style, which collapses the repeats. A face + // that also carries a narrower family name ("DejaVu Sans,DejaVu Sans + // Condensed") is the less canonical member of the family it is filed under, + // so that count breaks ties for a weight and style two faces both claim. + const families = new Map>(); + for (const line of result.stdout.split("\n")) { + const [familyList, styleName, weight, slant, postscriptName] = line.split("\t"); + if (!familyList || !weight || !slant) continue; + + const familyNames = familyList.split(","); + const family = familyNames[0]; + if (family.startsWith(".")) continue; + + // A variable font also lists its axis ranges (`[0 210]`); those describe + // no single variant, and its named instances come as their own lines. + const fcWeight = Number(weight); + const fcSlant = Number(slant); + if (!Number.isFinite(fcWeight) || !Number.isFinite(fcSlant)) continue; + + const css = fcWeightToCss(fcWeight); + const style = fcSlant === 0 ? "normal" : "italic"; + let variants = families.get(family); + if (!variants) { + variants = new Map(); + families.set(family, variants); + } + const key = `${css} ${style}`; + const names = familyNames.length; + const claimed = variants.get(key); + if (claimed && claimed.names <= names) continue; + + const fullName = !styleName || styleName === "Regular" ? family : `${family} ${styleName}`; + const locals = postscriptName ? [fullName, postscriptName] : [fullName]; + const source = locals.map((name) => `local('${name}')`).join(", "); + variants.set(key, { variant: { weight: css, style, source }, names }); + } + + // fc-list emits in cache order; sort so the listing reads like the macOS one. + return [...families] + .map(([family, variants]) => { + const sorted = [...variants.values()].map((entry) => entry.variant); + sorted.sort((a, b) => a.weight.localeCompare(b.weight) || a.style.localeCompare(b.style)); + return { family, variants: sorted }; + }) + .sort((a, b) => a.family.localeCompare(b.family)); +} + +function enumerateFonts(): FontFamily[] { + switch (platform()) { + case "darwin": + return listDarwinFonts(); + case "linux": + return listFontconfigFonts(); + default: + throw new Error("fonts is only supported on macOS and Linux."); + } +} + +export type ListLocalFontsOptions = { + familyPattern?: string; + weights?: string[]; + style?: "normal" | "italic"; + limit?: number; +}; + +export function listLocalFonts(options: ListLocalFontsOptions = {}): FontFamily[] { + const all = enumerateFonts(); const pattern = options.familyPattern?.toLowerCase(); const weights = options.weights && options.weights.length > 0 ? new Set(options.weights) : null; const { style, limit } = options; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 184dac9b..bfb03574 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -3,9 +3,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { Command } from "commander"; @@ -333,16 +333,77 @@ async function checkNode(id: string): Promise { type OpenOptions = { background?: boolean }; /** `open -a` on a running app only activates it, so this is safe to always run. */ -function launchApp(background: boolean): Promise { +function launchDarwin(background: boolean): Promise { const args = background ? ["-g", "-a", APP_NAME, "--args", "--hidden"] : ["-a", APP_NAME]; return new Promise((res) => execFile("open", args, (err) => res(!err))); } +// The app has to outlive this process, so the child is detached and its handle +// released. A missing binary is reported asynchronously, which is why the two +// events are raced instead of the call being wrapped in a try. +// +// `ELECTRON_RUN_AS_NODE` is how the packaged wrapper runs this CLI on the app's +// own Electron, and a child would inherit it - starting the app in node mode, +// where it runs no main script and exits without a window. The AppImage +// variables go too, so an image launched from here mounts itself afresh +// instead of reading the mount this process is running from. +function spawnDetached(command: string, args: string[]): Promise { + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + delete env.APPDIR; + delete env.APPIMAGE; + const { promise, resolve: res } = Promise.withResolvers(); + const child = spawn(command, args, { detached: true, stdio: "ignore", env }); + child.once("error", () => res(false)); + child.once("spawn", () => { + child.unref(); + res(true); + }); + return promise; +} + +// The executable electron-packager emits for the Linux build, which the deb +// and rpm packages also expose on PATH. +const LINUX_EXECUTABLE = "diffusion-studio"; + +// A second instance hands its argv to the running one, so relaunching the +// executable activates the app the same way `open -a` does on macOS. +async function launchLinux(background: boolean): Promise { + const args = background ? ["--hidden"] : []; + + // The packaged wrapper exports what it was shipped in, so an installed CLI + // starts its own app rather than whichever one is on PATH: the app root for + // a normal install, and for an AppImage the image file, which is itself the + // executable. + const shipped = process.env.DIFFUSION_APP_PATH; + const installed = statSync(shipped ?? "", { throwIfNoEntry: false })?.isDirectory() + ? join(shipped!, LINUX_EXECUTABLE) + : shipped; + if (installed && existsSync(installed) && (await spawnDetached(installed, args))) return true; + + if (await spawnDetached(LINUX_EXECUTABLE, args)) return true; + + // The deb and rpm packages register the `diffusion` scheme, so the desktop + // handler still finds the app when the executable is not on PATH. xdg-open + // hands the URL over and exits, reporting whether anything took it, and it + // forwards no arguments, so this last resort always surfaces a window. + const { promise, resolve: res } = Promise.withResolvers(); + execFile("xdg-open", ["diffusion://"], (err) => res(!err)); + return promise; +} + +function launchApp(background: boolean): Promise { + if (process.platform === "darwin") return launchDarwin(background); + if (process.platform === "linux") return launchLinux(background); + return Promise.resolve(false); +} + async function openProject(path: string | undefined, opts: OpenOptions): Promise { - // Launching is macOS's job; elsewhere (and when the app is not installed, - // e.g. a dev checkout run from the terminal) fall through to the socket, - // which answers if the app is running and errors usefully if not. - const launched = process.platform === "darwin" && (await launchApp(opts.background ?? false)); + // Launching needs a way to find the app; where there is none (and when the + // app is not installed, e.g. a dev checkout run from the terminal) fall + // through to the socket, which answers if the app is running and errors + // usefully if not. + const launched = await launchApp(opts.background ?? false); try { // A cold launch needs the renderer up before the app can answer; when @@ -791,7 +852,7 @@ program program .command("fonts") .description( - `List the local fonts available on this machine (macOS only; does not require the app). These family names are valid \`fontFamily\` values on ; each family lists its variants.`, + `List the local fonts available on this machine (macOS and Linux; does not require the app). These family names are valid \`fontFamily\` values on ; each family lists its variants.`, ) .option("-f, --family ", "filter to families whose name contains (case-insensitive)") .option("-w, --weight ", "filter to variants with the given CSS weight(s), e.g. -w 400 700") diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 5cfa8ce6..28a2b48f 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -3,7 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { ForgeConfig } from '@electron-forge/shared-types'; +import { MakerDeb, type MakerDebConfig } from '@electron-forge/maker-deb'; +import MakerAppImage, { type MakerAppImageConfig } from '@reforged/maker-appimage'; import { MakerDMG } from '@electron-forge/maker-dmg'; +import { MakerRpm, type MakerRpmConfig } from '@electron-forge/maker-rpm'; import { MakerZIP } from '@electron-forge/maker-zip'; import { PublisherGithub } from '@electron-forge/publisher-github'; import { readFileSync } from 'node:fs'; @@ -11,9 +14,58 @@ import { join } from 'node:path'; const { version } = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')); +// electron-packager derives the executable name from `name` and keeps the space +// in it, which is fine inside a .app bundle but not for a binary on $PATH. The +// deb and rpm packages symlink this into /usr/bin and the staged CLI wrapper +// execs it, so both sides agree on the lowercase form. +const LINUX_EXECUTABLE = 'diffusion-studio'; + +// The desktop entry every Linux artifact installs, kept as one file so the deb, +// the rpm, the AppImage and the Arch package cannot drift apart - and so the +// PKGBUILD, which no maker touches, reads the same text. +const DESKTOP_ENTRY = join(__dirname, '..', '..', 'packaging', 'linux', `${LINUX_EXECUTABLE}.desktop`); + +// What the makers themselves need: the entry above carries the FreeDesktop +// fields, these name the files. +const linuxDesktop = { + name: LINUX_EXECUTABLE, + // What the AppImage maker looks for inside the packaged tree, and what the + // deb and rpm symlink into /usr/bin. Both default to the sanitized package + // name (`diffusionstudio-desktop`), which is not what is packaged. + bin: LINUX_EXECUTABLE, + productName: 'Diffusion Studio', + icon: './assets/icon.png', + desktopFile: DESKTOP_ENTRY, +} satisfies NonNullable; + +// deb and rpm carry package metadata the desktop entry has no field for, and +// they take the entry under a different option name than the AppImage maker. +// `Maintainer` is one of the five fields dpkg-deb requires and is derived from +// `package.json`'s `author`, which this workspace does not set - without it the +// deb build fails outright. rpm's mandatory `License` does come from +// `package.json` (MPL-2.0), so it needs no packager. +const { desktopFile, ...linuxCommon } = linuxDesktop; +const linuxPackage = { + ...linuxCommon, + desktopTemplate: desktopFile, + description: 'Edit videos with coding agents, and refine any output in a full editing environment', + homepage: 'https://diffusion.studio', +} satisfies NonNullable & NonNullable; + +const debPackage = { + ...linuxPackage, + maintainer: 'Diffusion Studio Inc. ', +} satisfies NonNullable; + const config: ForgeConfig = { packagerConfig: { name: 'Diffusion Studio', + // Only Linux renames the binary; on macOS it stays inside the bundle as + // `Contents/MacOS/Diffusion Studio`, which the staged CLI wrapper and the + // signing pass both address by that name. Read from the build host, since + // that is what packages a platform here — cross-packaging Linux from macOS + // would have to set it. + executableName: process.platform === 'linux' ? LINUX_EXECUTABLE : undefined, appBundleId: 'studio.diffusion.editor', appCategoryType: 'public.app-category.video', appVersion: version, @@ -28,7 +80,7 @@ const config: ForgeConfig = { path !== '/web' && !path.startsWith('/web/'), // Staged by scripts/stage-{cli,docs,skills}.mjs; end up at - // Contents/Resources/{cli,docs,skills}. + // Contents/Resources/{cli,docs,skills} on macOS and resources/{...} on Linux. extraResource: ['./cli', './docs', './skills'], osxSign: process.env.SKIP_SIGN ? undefined : {}, osxNotarize: @@ -41,22 +93,31 @@ const config: ForgeConfig = { : undefined, }, makers: [ - new MakerZIP({}, ['darwin']), - new MakerDMG({ - name: `Diffusion-Studio-${process.arch}`, - icon: './assets/icon.icns', - // Dark, on-brand window; @2x sibling is picked up automatically for retina. - background: './assets/dmg-background.png', - iconSize: 120, - additionalDMGOptions: { - 'background-color': '#1c1c1c', - window: { size: { width: 658, height: 498 } }, + new MakerZIP({}, ['darwin', 'linux']), + new MakerDMG( + { + name: `Diffusion-Studio-${process.arch}`, + icon: './assets/icon.icns', + // Dark, on-brand window; @2x sibling is picked up automatically for retina. + background: './assets/dmg-background.png', + iconSize: 120, + additionalDMGOptions: { + 'background-color': '#1c1c1c', + window: { size: { width: 658, height: 498 } }, + }, + contents: (opts) => [ + { x: 188, y: 217, type: 'file', path: opts.appPath }, + { x: 470, y: 217, type: 'link', path: '/Applications' }, + ], }, - contents: (opts) => [ - { x: 188, y: 217, type: 'file', path: opts.appPath }, - { x: 470, y: 217, type: 'link', path: '/Applications' }, - ], - }), + ['darwin'], + ), + new MakerDeb({ options: debPackage }, ['linux']), + new MakerRpm({ options: linuxPackage }, ['linux']), + // The format that runs on a distribution the deb and rpm do not cover: + // one file, no root, no package manager. Needs `mksquashfs` on the build + // host (squashfs-tools). + new MakerAppImage({ options: linuxDesktop }, ['linux']), ], publishers: [ new PublisherGithub({ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 20c0d2ba..e7464f13 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,13 +22,17 @@ "package": "npm run build && npm run build:web && npm run stage:cli && npm run stage:docs && npm run stage:skills && electron-forge package", "make": "npm run build && npm run build:web && npm run stage:cli && npm run stage:docs && npm run stage:skills && electron-forge make", "publish": "npm run build && npm run build:web && npm run stage:cli && npm run stage:docs && npm run stage:skills && electron-forge publish", + "make:arch": "npm run package && node scripts/make-arch.mjs", "make:icns": "sh scripts/make-icns.sh" }, "devDependencies": { "@electron-forge/cli": "^7.11.1", + "@electron-forge/maker-deb": "^7.11.1", "@electron-forge/maker-dmg": "^7.11.1", + "@electron-forge/maker-rpm": "^7.11.1", "@electron-forge/maker-zip": "^7.11.1", "@electron-forge/publisher-github": "^7.11.1", + "@reforged/maker-appimage": "^5.3.1", "@types/babel__core": "^7.20.5", "@types/node": "^24.10.1", "electron": "^43.1.1", diff --git a/apps/desktop/scripts/make-arch.mjs b/apps/desktop/scripts/make-arch.mjs new file mode 100644 index 00000000..fbe079d7 --- /dev/null +++ b/apps/desktop/scripts/make-arch.mjs @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// Builds an Arch Linux package (.pkg.tar.zst) from the `electron-forge package` +// output. Electron Forge has no Arch maker, so this is a script rather than a +// maker; run it after `npm run package` (or `npm run make`). +// +// Adapted from https://github.com/diffusionstudio/editor/pull/42 by @Tsurgcom. +// The staged tree lives in out/arch and the package lands beside it. +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = join(desktopDir, "..", ".."); +const require = createRequire(import.meta.url); +const { version } = require(join(repoRoot, "package.json")); + +const PKG_NAME = "diffusion-studio"; +const outDir = join(desktopDir, "out"); + +// electron-packager names the directory after the product, not the executable. +const packaged = readdirSync(outDir).find((entry) => entry.startsWith("Diffusion Studio-linux-")); +if (!packaged) { + console.error("make-arch: nothing packaged in out/. Run `npm run package` first."); + process.exit(1); +} + +const archDir = join(outDir, "arch"); +rmSync(archDir, { recursive: true, force: true }); +mkdirSync(archDir, { recursive: true }); + +// What makepkg unpacks: the app plus the two files the PKGBUILD installs. +const srcName = `${PKG_NAME}-${version}-linux-x64`; +const srcDir = join(archDir, srcName); +cpSync(join(outDir, packaged), srcDir, { recursive: true }); +cpSync(join(repoRoot, "packaging", "linux", `${PKG_NAME}.desktop`), join(srcDir, `${PKG_NAME}.desktop`)); +cpSync(join(desktopDir, "assets", "icon.png"), join(srcDir, `${PKG_NAME}.png`)); + +const tarball = `${srcName}.tar.gz`; +execFileSync("tar", ["-czf", join(archDir, tarball), "-C", archDir, srcName], { stdio: "inherit" }); + +writeFileSync( + join(archDir, "PKGBUILD"), + readFileSync(join(repoRoot, "packaging", "arch", "PKGBUILD"), "utf8").replaceAll("__VERSION__", version), +); + +// makepkg refuses to run as root, which is what a container CI job starts as - +// the workflow builds this step as an unprivileged user for that reason. +execFileSync("makepkg", ["-f", "--noconfirm"], { + cwd: archDir, + stdio: "inherit", + env: { ...process.env, PACKAGER: "Diffusion Studio Inc. " }, +}); + +const built = readdirSync(archDir).find((entry) => entry.endsWith(".pkg.tar.zst")); +console.log(`make-arch: wrote ${join(archDir, built)}`); diff --git a/apps/desktop/scripts/stage-cli.mjs b/apps/desktop/scripts/stage-cli.mjs index 90590cda..947dbb05 100644 --- a/apps/desktop/scripts/stage-cli.mjs +++ b/apps/desktop/scripts/stage-cli.mjs @@ -50,7 +50,12 @@ execFileSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund", "--no-p // The wrapper runs the CLI bundle on the app's own Electron binary in Node // mode, so users need no separate Node install. It resolves symlinks first -// because both Homebrew and the in-app installer link it into PATH. +// because both Homebrew and the in-app installer link it into PATH, then +// works out which packaged layout it sits in: on macOS the wrapper is at +// Contents/Resources/cli/bin, beside Contents/MacOS, while electron-packager +// emits a flat Linux tree with resources/ next to the executable — one level +// closer to the app root. The Linux name is packagerConfig.executableName +// (see forge.config.ts), which only that build renames. const wrapper = `#!/bin/sh SELF="$0" while [ -L "$SELF" ]; do @@ -61,8 +66,19 @@ while [ -L "$SELF" ]; do esac done DIR="$(cd "$(dirname "$SELF")" && pwd)" -export DIFFUSION_APP_PATH="$(cd "$DIR/../../../.." && pwd)" -ELECTRON_RUN_AS_NODE=1 exec "$DIR/../../../MacOS/Diffusion Studio" "$DIR/../dapi.js" "$@" +if [ -x "$DIR/../../../MacOS/Diffusion Studio" ]; then + ELECTRON="$DIR/../../../MacOS/Diffusion Studio" + APP_ROOT="$DIR/../../../.." +else + APP_ROOT="$DIR/../../.." + ELECTRON="$APP_ROOT/diffusion-studio" + if [ ! -x "$ELECTRON" ]; then + echo "dapi: no application executable at $ELECTRON" >&2 + exit 1 + fi +fi +export DIFFUSION_APP_PATH="$(cd "$APP_ROOT" && pwd)" +ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$DIR/../dapi.js" "$@" `; writeFileSync(join(stageDir, "bin", "dapi"), wrapper); chmodSync(join(stageDir, "bin", "dapi"), 0o755); diff --git a/apps/desktop/src/cli-install.ts b/apps/desktop/src/cli-install.ts index 8e7d800d..c88a3970 100644 --- a/apps/desktop/src/cli-install.ts +++ b/apps/desktop/src/cli-install.ts @@ -4,31 +4,81 @@ import { app } from "electron"; import { execFile } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, relative } from "node:path"; import type { CliInstallResult } from "./main-channels"; -export const CLI_LINK_PATH = "/usr/local/bin/dapi"; +// Where each platform keeps user-installed commands: /usr/local/bin needs +// elevation, ~/.local/bin belongs to the user, so linking there asks for +// nothing. Debian and Fedora put it on PATH for you, but not every +// distribution does, so the menu checks before claiming the command is ready. +export const CLI_LINK_PATH = + process.platform === "linux" ? join(homedir(), ".local", "bin", "dapi") : "/usr/local/bin/dapi"; // The dev workflow links the workspace build into Homebrew's bin instead // (`symlink:create` in apps/cli), so both locations count as installed. const DEV_LINK_PATH = "/opt/homebrew/bin/dapi"; export function isCliInstalled(): boolean { - return existsSync(CLI_LINK_PATH) || existsSync(DEV_LINK_PATH); + if (existsSync(CLI_LINK_PATH)) return true; + return process.platform === "darwin" && existsSync(DEV_LINK_PATH); } +// The staged wrapper inside the app's resources, the file both platforms link. +const CLI_WRAPPER_PATH = join(process.resourcesPath, "cli", "bin", "dapi"); + // Linking into /usr/local/bin needs elevation; osascript shows the standard // macOS admin prompt so the app itself never asks for credentials. -function linkCli(): Promise { - const wrapper = join(process.resourcesPath, "cli", "bin", "dapi"); - const shell = `mkdir -p /usr/local/bin && ln -sf '${wrapper}' '${CLI_LINK_PATH}'`; +function linkCliWithPrompt(): Promise { + const shell = `mkdir -p /usr/local/bin && ln -sf '${CLI_WRAPPER_PATH}' '${CLI_LINK_PATH}'`; const script = `do shell script "${shell.replaceAll('"', '\\"')}" with administrator privileges`; return new Promise((resolve, reject) => { execFile("osascript", ["-e", script], (err) => (err ? reject(err) : resolve())); }); } +// The user owns ~/.local/bin, so the link is a plain filesystem operation. +// Replacing an existing link mirrors `ln -sf`. +function linkCliDirectly(): void { + mkdirSync(dirname(CLI_LINK_PATH), { recursive: true }); + rmSync(CLI_LINK_PATH, { force: true }); + symlinkSync(CLI_WRAPPER_PATH, CLI_LINK_PATH); +} + +// An AppImage has nothing worth linking to: it runs from a mount under /tmp +// that exists only while the process does, and whose name changes every +// launch, so a symlink into `resources` dangles the moment the app quits. The +// image file itself is stable and can run the CLI through Electron's node +// mode, mounting itself for the duration of the call - so the install writes a +// wrapper that does that. The path inside the mount is read from this process +// rather than assumed, since it is the maker that decides the layout. +function writeAppImageWrapper(appImage: string, appDir: string): void { + const insideMount = relative(appDir, dirname(process.resourcesPath)); + // Runs in the child: the runtime exports APPDIR for the mount it made and + // APPIMAGE for the file, which is what `dapi open` needs to launch the app. + const bootstrap = + `const r=process.env.APPDIR+"/${insideMount}";` + + `process.env.DIFFUSION_APP_PATH=process.env.APPIMAGE;` + + `const j=r+"/resources/cli/dapi.js";` + + `process.argv=[process.argv[0],j,...process.argv.slice(1)];` + + `require(j);`; + const quoted = `'${appImage.replaceAll("'", `'\\''`)}'`; + const wrapper = [ + "#!/bin/sh", + "# Written by Diffusion Studio's \"Install dapi Command Line Tool\" from an", + "# AppImage. Move or delete that file and this stops working; run the", + "# installer again to point it at the new location.", + `APPIMAGE=${quoted}`, + `[ -x "$APPIMAGE" ] || { echo "dapi: no Diffusion Studio AppImage at $APPIMAGE" >&2; exit 1; }`, + `ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e '${bootstrap}' -- "$@"`, + "", + ].join("\n"); + mkdirSync(dirname(CLI_LINK_PATH), { recursive: true }); + rmSync(CLI_LINK_PATH, { force: true }); + writeFileSync(CLI_LINK_PATH, wrapper, { mode: 0o755 }); +} + export async function installCli(): Promise { if (!app.isPackaged) { return { @@ -36,8 +86,11 @@ export async function installCli(): Promise { error: "Installing the CLI is only available in the packaged app. Use `npm run symlink:create` in development.", }; } + const { APPIMAGE, APPDIR } = process.env; try { - await linkCli(); + if (APPIMAGE && APPDIR) writeAppImageWrapper(APPIMAGE, APPDIR); + else if (process.platform === "linux") linkCliDirectly(); + else await linkCliWithPrompt(); return { status: "installed" }; } catch (e) { const message = (e as Error).message ?? ""; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b965b054..0af50f9d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,6 +57,15 @@ app.commandLine.appendSwitch("disable-renderer-backgrounding"); app.commandLine.appendSwitch("disable-backgrounding-occluded-windows"); app.commandLine.appendSwitch("disable-features", "CalculateNativeWinOcclusion"); +// No ozone hint is set here on purpose. Native Wayland would buy fractional +// scaling, but on a KDE 6.7 Wayland session with the proprietary NVIDIA driver +// it presents a broken surface — Chromium logs that Wayland and Vulkan are +// incompatible, the renderer paints correctly (a DevTools capture is right) +// and the window still comes up blank or without glyphs. XWayland, which is +// what Chromium picks by default, is correct there. Electron reads +// `ELECTRON_OZONE_PLATFORM_HINT=auto` from the environment, so anyone whose +// session handles native Wayland can opt in without a build of their own. + let setNativeCornerRadius: ((handle: Buffer, radius: number) => void) | null = null; let setNativeBackdrop: | ((handle: Buffer, blur: number, r: number, g: number, b: number, a: number) => void) diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts index 31bef6aa..175a1632 100644 --- a/apps/desktop/src/menu.ts +++ b/apps/desktop/src/menu.ts @@ -4,6 +4,7 @@ import { app, dialog, Menu } from "electron"; import type { MenuItemConstructorOptions } from "electron"; +import { dirname } from "node:path"; import { CLI_LINK_PATH, installCli } from "./cli-install"; @@ -11,10 +12,17 @@ async function installCliFromMenu() { const result = await installCli(); if (result.status === "cancelled") return; if (result.status === "installed") { + // The session PATH is what a launcher-started app inherits; a directory + // only a shell rc file adds reads as missing, so this is worded as a + // condition rather than a claim about the user's shell. + const linkDir = dirname(CLI_LINK_PATH); + const onPath = (process.env.PATH ?? "").split(":").includes(linkDir); await dialog.showMessageBox({ type: "info", message: "The dapi command line tool was installed.", - detail: `Linked at ${CLI_LINK_PATH}. Run "dapi --help" in a terminal to get started.`, + detail: onPath + ? `Installed at ${CLI_LINK_PATH}. Run "dapi --help" in a terminal to get started.` + : `Installed at ${CLI_LINK_PATH}. If "dapi" is not found, add ${linkDir} to your PATH.`, }); } else { await dialog.showMessageBox({ @@ -25,20 +33,24 @@ async function installCliFromMenu() { } } -export function setupAppMenu() { - if (process.platform !== "darwin") return; +/** The one app-specific item both menus carry; only the packaged app can link it. */ +function installCliItem(): MenuItemConstructorOptions { + return { + label: "Install dapi Command Line Tool…", + enabled: app.isPackaged, + click: installCliFromMenu, + }; +} - const template: MenuItemConstructorOptions[] = [ +/** The macOS menu: the app menu holds the item, in its usual place. */ +function macTemplate(): MenuItemConstructorOptions[] { + return [ { label: app.name, submenu: [ { role: "about" }, { type: "separator" }, - { - label: "Install dapi Command Line Tool…", - enabled: app.isPackaged, - click: installCliFromMenu, - }, + installCliItem(), { type: "separator" }, { role: "services" }, { type: "separator" }, @@ -54,6 +66,27 @@ export function setupAppMenu() { { role: "viewMenu" }, { role: "windowMenu" }, ]; +} + +/** + * Everywhere else there is no app menu, so the item goes under File. Without + * a template of our own Electron shows its stock menu, which has no way to + * reach the installer at all — and the roles the macOS template uses + * (`about`, `services`, `hide`) are AppKit's, so they have no place here. + */ +function defaultTemplate(): MenuItemConstructorOptions[] { + return [ + { + label: "File", + submenu: [installCliItem(), { type: "separator" }, { role: "quit" }], + }, + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + ]; +} +export function setupAppMenu() { + const template = process.platform === "darwin" ? macTemplate() : defaultTemplate(); Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } diff --git a/apps/web/src/components/sidebar-right/inspector/export-templates.ts b/apps/web/src/components/sidebar-right/inspector/export-templates.ts index 56f96473..03deda29 100644 --- a/apps/web/src/components/sidebar-right/inspector/export-templates.ts +++ b/apps/web/src/components/sidebar-right/inspector/export-templates.ts @@ -23,6 +23,7 @@ export const RESOLUTION_OPTIONS: number[] = [720, 1080, 1440, 2160]; export const VIDEO_CODEC_OPTIONS: VideoCodec[] = ["avc", "hevc", "vp9", "av1", "vp8"]; export const VIDEO_FORMAT_OPTIONS: ContainerFormat[] = ["mp4", "webm", "ogg", "mov"]; export const FRAME_RATE_OPTIONS: number[] = [24, 25, 29.97, 30, 48, 50, 59.94, 60]; +/** The codecs the audio picker offers, less the ones a machine cannot encode (see `ExportPanel`). */ export const AUDIO_CODEC_OPTIONS: AudioCodec[] = ["aac", "opus"]; export const SAMPLE_RATE_OPTIONS: number[] = [44100, 48000, 96000]; diff --git a/apps/web/src/components/sidebar-right/inspector/export.tsx b/apps/web/src/components/sidebar-right/inspector/export.tsx index 76dcb346..be8b4648 100644 --- a/apps/web/src/components/sidebar-right/inspector/export.tsx +++ b/apps/web/src/components/sidebar-right/inspector/export.tsx @@ -3,8 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Show, createMemo, createResource, createSignal } from "solid-js"; -import { canEncodeVideo } from "mediabunny"; -import { computeOutputSize } from "@diffusionstudio/encoder"; +import { canEncodeVideo, getEncodableAudioCodecs } from "mediabunny"; +import { audioCodecsForFormat, computeOutputSize, resolveAudioCodec } from "@diffusionstudio/encoder"; import { PanelSection } from "@/components/ui/panel-section"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -158,6 +158,28 @@ export function ExportPanel(props: ExportPanelProps) { ({ codec, bitrate, width, height }) => canEncodeVideo(codec, { width, height, bitrate }), ); + // Which audio codecs are worth offering: those the container takes and + // this browser can encode. AAC is a platform encoder in WebCodecs + // (AudioToolbox on macOS, Media Foundation on Windows) and simply absent + // on Linux, so offering it there would only fail the export. + const [audioCodecs] = createResource( + () => { + const current = settings(); + return { + format: current?.format ?? ("mp4" as const), + sampleRate: current?.audio?.sampleRate, + bitrate: current?.audio?.bitrate, + }; + }, + async ({ format, ...options }) => { + const [supported, encodable] = await Promise.all([ + audioCodecsForFormat(format), + getEncodableAudioCodecs([...AUDIO_CODEC_OPTIONS], options), + ]); + return encodable.filter((codec) => supported.includes(codec)); + }, + ); + // Audio-only exports always encode const exportSupported = createMemo(() => { const current = settings(); @@ -180,14 +202,21 @@ export function ExportPanel(props: ExportPanelProps) { void config()?.setExport(entity(), value); }; - // Replaces the settings with a preset's, wholesale. - const applyTemplate = (id: string) => { + // Replaces the settings with a preset's, wholesale. A preset names AAC for + // mp4, which is a preference rather than a demand — what is written is the + // codec this browser can encode into the container. + const applyTemplate = async (id: string) => { const next = templateSettings(id); - if (next) write(next); + if (!next) return; + const codec = await resolveAudioCodec(next.format, next.audio?.codec, { + sampleRate: next.audio?.sampleRate, + bitrate: next.audio?.bitrate, + }); + write(codec ? { ...next, audio: { ...next.audio, codec } } : next); }; const addSettings = () => { - applyTemplate(DEFAULT_EXPORT_TEMPLATE_ID); + void applyTemplate(DEFAULT_EXPORT_TEMPLATE_ID); setIsInspectorOpen(true); }; @@ -294,6 +323,7 @@ export function ExportPanel(props: ExportPanelProps) { settings={current()} resolutionOptions={resolutionOptions()} selectedResolution={selectedResolution()} + audioCodecOptions={audioCodecs() ?? AUDIO_CODEC_OPTIONS} anchorRef={inspectorAnchorRef} onClose={() => setIsInspectorOpen(false)} onSelectTemplate={applyTemplate} @@ -309,6 +339,7 @@ type ExportInspectorProps = { settings: ProjectExportConfig; resolutionOptions: ResolutionOption[]; selectedResolution: ResolutionOption | null; + audioCodecOptions: AudioCodec[]; anchorRef: HTMLDivElement | undefined; onClose: () => void; onSelectTemplate: (id: string) => void; @@ -507,7 +538,7 @@ function ExportInspector(props: ExportInspectorProps) {