Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 117 additions & 12 deletions apps/cli/src/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,131 @@ 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,
});
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<string, Map<string, { variant: FontVariant; names: number }>>();
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;
Expand Down
77 changes: 69 additions & 8 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -333,16 +333,77 @@ async function checkNode(id: string): Promise<void> {
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<boolean> {
function launchDarwin(background: boolean): Promise<boolean> {
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<boolean> {
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
delete env.APPDIR;
delete env.APPIMAGE;
const { promise, resolve: res } = Promise.withResolvers<boolean>();
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<boolean> {
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<boolean>();
execFile("xdg-open", ["diffusion://"], (err) => res(!err));
return promise;
}

function launchApp(background: boolean): Promise<boolean> {
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<void> {
// 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
Expand Down Expand Up @@ -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 <text>; 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 <text>; each family lists its variants.`,
)
.option("-f, --family <pattern>", "filter to families whose name contains <pattern> (case-insensitive)")
.option("-w, --weight <weights...>", "filter to variants with the given CSS weight(s), e.g. -w 400 700")
Expand Down
Loading