Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Desktop app
on:
workflow_dispatch:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: desktop/package-lock.json
- run: npm ci
working-directory: desktop
- run: npm run dist
working-directory: desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- uses: actions/upload-artifact@v4
with:
name: flux-desktop-${{ matrix.os }}
path: |
desktop/dist/*.exe
desktop/dist/*.dmg
desktop/dist/*.AppImage
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${{ github.ref_name }}" desktop/dist/*.exe desktop/dist/*.dmg desktop/dist/*.AppImage --clobber 2>/dev/null || gh release upload "${{ github.ref_name }}" $(ls desktop/dist/*.exe desktop/dist/*.dmg desktop/dist/*.AppImage 2>/dev/null) --clobber
3 changes: 3 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
app-server/
dist/
18 changes: 18 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Flux desktop app

An Electron wrapper that runs the regular Flux server on `127.0.0.1` and shows it in a window. No Node, Docker or setup needed for users. The server code is unchanged; the app only sets environment variables (`FLUX_LOCAL_MODE=1`, data in the OS app-data folder).

- **yt-dlp** is downloaded (checksum-verified) on first launch into the app-data folder and self-updates on later launches.
- **FFmpeg / FFprobe** are bundled through `ffmpeg-static` and `ffprobe-static`.
- **Local mode** (`FLUX_LOCAL_MODE=1`) treats the person running the app as signed in, so full-length downloads work without Google sign-in. It is ignored unless the server listens on `127.0.0.1`.

## Develop

```bash
cd desktop
npm ci
npm start # copies ../server.mjs, ../lib, ../public into app-server/ and opens the app
npm run dist # builds an installer for the current OS into dist/
```

Releases are built for Windows (`.exe`), macOS (`.dmg`) and Linux (`.AppImage`) by `.github/workflows/desktop.yml` when a `v*` tag is pushed. Builds are unsigned, so Windows SmartScreen and macOS Gatekeeper will warn on first open.
96 changes: 96 additions & 0 deletions desktop/main.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Electron shell for Flux. It runs the regular Flux server on a loopback port
// and shows it in a window. The server code itself is unchanged.
import { app, BrowserWindow, dialog, shell } from "electron";
import net from "node:net";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ensureYtDlp } from "./ytdlp.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const PREFERRED_PORT = 38417; // fixed so the page origin (and its saved settings) stays stable

if (!app.requestSingleInstanceLock()) app.quit();

function freePort(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(freePort(0)));
probe.listen(port, "127.0.0.1", () => {
const { port: got } = probe.address();
probe.close(() => resolve(got));
});
});
}

let server;
let win;

async function start() {
const userData = app.getPath("userData");
const port = await freePort(PREFERRED_PORT);
Object.assign(process.env, {
FLUX_LOCAL_MODE: "1",
HOST: "127.0.0.1",
PORT: String(port),
FLUX_ORIGIN: `http://localhost:${port}`,
FLUX_DATA_DIR: path.join(userData, "data"),
YTDLP_PATH: await ensureYtDlp(path.join(userData, "bin")),
FFMPEG_PATH: require("ffmpeg-static"),
FFPROBE_PATH: require("ffprobe-static").path,
});
const { createServer } = await import(
pathToFileURL(path.join(here, "app-server", "server.mjs")).href
);
server = createServer();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolve);
});

win = new BrowserWindow({
width: 1240,
height: 820,
minWidth: 380,
minHeight: 560,
title: "Flux",
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
const origin = `http://localhost:${port}`;
// Only the local Flux page may load in the window; everything else opens in the browser.
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:/.test(url)) shell.openExternal(url);
return { action: "deny" };
});
win.webContents.on("will-navigate", (event, url) => {
if (!url.startsWith(origin)) {
event.preventDefault();
if (/^https?:/.test(url)) shell.openExternal(url);
}
});
await win.loadURL(origin);
}

app.whenReady().then(() =>
start().catch((error) => {
dialog.showErrorBox(
"Flux could not start",
`${error.message}\n\nThe first launch needs an internet connection to fetch yt-dlp.`,
);
app.quit();
}),
);
app.on("second-instance", () => {
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
}
});
app.on("window-all-closed", () => app.quit());
app.on("before-quit", () => server?.close());
Loading
Loading