From f83c9730943f5b81bd6133d2aaa0dfef8def03b0 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 18 Oct 2026 15:33:05 -0700 Subject: [PATCH] Streamplace Desktop: a home screen for its node, and OBS setup Desktop opens /desktop, a new js/app screen: a preview of the signed-in streamer's stream beside the renditions OBS encodes when it streams to this node (place.stream.ingest.* records kept on the node), and, inside Desktop, its OBS setup. Desktop now ships the OBS plugin built from the same commit, unpacked into its resources. On boot it offers to install or update it when OBS could load it (not Flatpak OBS; on Windows only with OBS closed), never downgrading: package.sh stamps the plugin with a manifest Desktop compares by build time. The screen's OBS card does the same on demand, and points OBS's current profile at this node with a new stream key while OBS is closed. CI builds the plugin before packaging Desktop. The e2e harness exports STREAM_KEY, which flows/desktop.spec.ts uses to ask the node what the plugin asks. --- .github/workflows/build.yaml | 4 +- .gitlab-ci.yml | 2 +- Makefile | 4 +- hack/lib/e2e-harness.sh | 7 +- js/app/components/desktop/obs-card.tsx | 144 +++++++++ .../components/desktop/rendition-settings.tsx | 219 +++++++++++++ js/app/src/linking-config.ts | 2 + js/app/src/navigation-types.ts | 1 + js/app/src/screens/desktop.tsx | 83 +++++ js/app/src/shell.tsx | 11 +- js/desktop/README.md | 11 + js/desktop/forge.config.ts | 36 ++- js/desktop/package.json | 1 + js/desktop/src/index.ts | 54 +++- js/desktop/src/obs.test.ts | 138 ++++++++ js/desktop/src/obs.ts | 301 ++++++++++++++++++ js/desktop/src/preload.ts | 9 +- js/e2e-web/flows/desktop.spec.ts | 95 ++++++ obs-plugin/package.sh | 12 +- package.json | 2 +- 20 files changed, 1118 insertions(+), 18 deletions(-) create mode 100644 js/app/components/desktop/obs-card.tsx create mode 100644 js/app/components/desktop/rendition-settings.tsx create mode 100644 js/app/src/screens/desktop.tsx create mode 100644 js/desktop/src/obs.test.ts create mode 100644 js/desktop/src/obs.ts create mode 100644 js/e2e-web/flows/desktop.spec.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 197002ae1..bbd41764d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -20,7 +20,9 @@ jobs: - name: linux-arm64 cmd: "make linux-arm64" - name: windows-amd64 - cmd: "make windows-amd64 && make desktop-windows-amd64" + cmd: + "make windows-amd64 && make obs-plugin-windows-amd64 && make + desktop-windows-amd64" # The e2e build of the release APK (see `make android-e2e`), which # android-e2e runs. Shipped releases are built by GitLab. - name: android diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 17faadfa8..ac4394692 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,7 +26,7 @@ variables: script: - rm -rf /usr/x86_64-w64-mingw32/include/d3dcompiler.h /usr/share/mingw-w64/include/d3dcompiler.h /usr/lib/gcc/x86_64-w64-mingw32/10-posix/libssp.dll.a /usr/lib/gcc/x86_64-w64-mingw32/10-posix/libssp-0.dll /usr/lib/gcc/x86_64-w64-mingw32/10-win32/libssp-0.dll /usr/lib/gcc/x86_64-w64-mingw32/10-win32/libssp.dll.a - git fetch --unshallow || echo 'already unshallow' - - bash -c "make version && make $TARGET && make test-vod && make desktop-$TARGET && make pkg-$TARGET && make obs-plugin-$TARGET && make ci-upload-node-$TARGET -j$(nproc) && make ci-upload-obs-plugin-$TARGET" + - bash -c "make version && make $TARGET && make test-vod && make obs-plugin-$TARGET && make desktop-$TARGET && make pkg-$TARGET && make ci-upload-node-$TARGET -j$(nproc) && make ci-upload-obs-plugin-$TARGET" artifacts: reports: dotenv: .ci/build.env diff --git a/Makefile b/Makefile index f147c0ce7..49dddbbea 100644 --- a/Makefile +++ b/Makefile @@ -556,7 +556,8 @@ ci-desktop-darwin: version install && ./streamplace --version \ && ./streamplace self-test \ && mkdir -p build-darwin-$$arch \ - && mv ./streamplace ./build-darwin-$$arch/streamplace; \ + && mv ./streamplace ./build-darwin-$$arch/streamplace \ + && $(MAKE) ci-download-file download_file=obs-streamplace-$(VERSION)-darwin-$$arch.tar.gz; \ done \ && $(MAKE) desktop-darwin \ && for arch in amd64 arm64; do \ @@ -618,6 +619,7 @@ node-all-platforms: app $(MAKE) linux-arm64 $(MAKE) windows-amd64 $(MAKE) windows-amd64-startup-test + $(MAKE) obs-plugin-linux-amd64 obs-plugin-linux-arm64 obs-plugin-windows-amd64 $(MAKE) desktop-linux $(MAKE) desktop-windows-amd64 diff --git a/hack/lib/e2e-harness.sh b/hack/lib/e2e-harness.sh index e354d8ddd..943e21a5e 100644 --- a/hack/lib/e2e-harness.sh +++ b/hack/lib/e2e-harness.sh @@ -10,8 +10,9 @@ # the stream from the first over MoQ), SERVER3_URL (a third node the first # pushes the stream to over MoQ), ACCOUNT_HANDLE, ACCOUNT_DID, # ACCOUNT_PASSWORD, VIDEO_URI (the account's test VOD), E2E_FIXTURE_MP4 (the -# local file the harness streams into the node), PDS_HTTP_URL (the PDS's -# plain-HTTP listener, for checking stored records from the host), and in +# local file the harness streams into the node), STREAM_KEY (the account's +# stream key), PDS_HTTP_URL (the PDS's plain-HTTP listener, for checking +# stored records from the host), and in # HTTPS mode also SERVER_HTTPS_URL, PDS_HTTPS_URL, # E2E_PROXY_URL, E2E_TLS_SPKI and E2E_TLS_CA. An EXIT trap stops it; a caller # with its own EXIT work calls e2e_harness_stop from its trap instead. @@ -83,7 +84,7 @@ e2e_harness_start() { # shellcheck disable=SC1090 . "$E2E_ENVFILE" export SERVER_URL SERVER2_URL SERVER3_URL ACCOUNT_HANDLE ACCOUNT_DID ACCOUNT_PASSWORD VIDEO_URI - export E2E_FIXTURE_MP4 PDS_HTTP_URL + export E2E_FIXTURE_MP4 STREAM_KEY PDS_HTTP_URL # Every node's log, for specs that check how something happened. export E2E_LOGFILE # only set in HTTPS mode diff --git a/js/app/components/desktop/obs-card.tsx b/js/app/components/desktop/obs-card.tsx new file mode 100644 index 000000000..4e5326ff6 --- /dev/null +++ b/js/app/components/desktop/obs-card.tsx @@ -0,0 +1,144 @@ +import { + Button, + MenuContainer, + MenuGroup, + MenuItem, + MenuLabel, + Text, + useTheme, + useToast, + View, +} from "@streamplace/components"; +import { useCallback, useEffect, useState } from "react"; +import { useStore } from "store"; + +interface ObsStatus { + obs: "absent" | "installed" | "flatpak"; + plugin: "absent" | "outdated" | "current"; + obsRunning: boolean; + bundledVersion: string; + installedVersion?: string; +} + +interface Result { + ok: boolean; + error?: string; +} + +// What Streamplace Desktop's preload exposes (js/desktop/src/preload.ts). +interface ObsBridge { + status(): Promise; + installPlugin(): Promise; + configureStream(streamKey: string): Promise; +} + +const obsBridge = (): ObsBridge | undefined => + typeof window === "undefined" ? undefined : (window as any).SP_ELECTRON?.obs; + +const describe = (s: ObsStatus): string => { + if (s.obs === "absent") return "OBS isn't installed."; + if (s.obs === "flatpak") + return "Flatpak OBS can't load the Streamplace plugin; install OBS from your distribution or obsproject.com."; + if (s.plugin === "absent") return "The Streamplace plugin isn't installed."; + if (s.plugin === "outdated") + return `The Streamplace plugin is out of date (${s.installedVersion}).`; + return `The Streamplace plugin is installed (${s.bundledVersion}).`; +}; + +/** + * Streamplace Desktop's OBS setup: installing the plugin it ships, and + * pointing OBS at this node with a new stream key. Nothing outside Desktop. + */ +export function ObsCard() { + const { theme } = useTheme(); + const toast = useToast(); + const bridge = obsBridge(); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const createStreamKeyRecord = useStore((s) => s.createStreamKeyRecord); + const clearStreamKeyRecord = useStore((s) => s.clearStreamKeyRecord); + + const refresh = useCallback(async () => { + if (bridge) setStatus(await bridge.status()); + }, [bridge]); + useEffect(() => { + refresh(); + }, [refresh]); + + if (!bridge || !status) { + return null; + } + + const run = async (action: () => Promise, done: string) => { + setBusy(true); + try { + const result = await action(); + if (result.ok) { + toast.show("OBS", done); + } else { + toast.show("OBS", result.error ?? "Something went wrong.", { + variant: "error", + }); + } + await refresh(); + } finally { + setBusy(false); + } + }; + + const install = () => + run( + () => bridge.installPlugin(), + status.obsRunning + ? "Plugin installed. Restart OBS to load it." + : "Plugin installed.", + ); + + // A fresh key each time: Desktop never sees a key's secret again after + // creating it, so connecting OBS is what creates one. + const connect = () => + run(async () => { + await createStreamKeyRecord(false); + const key = useStore.getState().newKey; + clearStreamKeyRecord(); + if (!key) { + return { ok: false, error: "Couldn't create a stream key." }; + } + return bridge.configureStream(key.privateKey); + }, "OBS now streams to Streamplace Desktop."); + + const installable = status.obs === "installed"; + return ( + + OBS + + + + + {describe(status)} + + {installable && status.plugin !== "current" && ( + + )} + {installable && status.plugin === "current" && ( + <> + + {status.obsRunning && ( + + Close OBS to change its stream settings. + + )} + + )} + + + + + ); +} diff --git a/js/app/components/desktop/rendition-settings.tsx b/js/app/components/desktop/rendition-settings.tsx new file mode 100644 index 000000000..387a52675 --- /dev/null +++ b/js/app/components/desktop/rendition-settings.tsx @@ -0,0 +1,219 @@ +import { + Button, + Input, + MenuContainer, + MenuGroup, + MenuItem, + MenuLabel, + SegmentedTabs, + Select, + Text, + useTheme, + useToast, + View, +} from "@streamplace/components"; +import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import { SettingToggle } from "components/settings/components/setting-toggle"; +import { useEffect, useState } from "react"; +import { place } from "streamplace"; + +const VIDEO = "place.stream.ingest.videoRendition"; +const AUDIO = "place.stream.ingest.audioRendition"; +type Collection = typeof VIDEO | typeof AUDIO; + +// Video sizes by height. One video track for now; the records are a list so +// a second encode (AV1) is another record, not a new shape. +const SIZES: Record = { + "720": { width: 1280, height: 720 }, + "1080": { width: 1920, height: 1080 }, + "1440": { width: 2560, height: 1440 }, + "2160": { width: 3840, height: 2160 }, +}; + +const AUDIO_BITRATES = { aac: 160, opus: 128 } as const; +type AudioCodec = keyof typeof AUDIO_BITRATES; + +interface Settings { + video: boolean; + size: string; + fps: string; + bitrate: string; + aac: boolean; + opus: boolean; +} + +const DEFAULTS: Settings = { + video: false, + size: "1080", + fps: "60", + bitrate: "6000", + aac: false, + opus: false, +}; + +/** + * The renditions OBS encodes when it streams to this node, kept on the node + * as place.stream.ingest.* records (place.stream.local.*). With none of a + * kind, OBS uses its own encoder for it. + */ +export function RenditionSettings() { + const { theme } = useTheme(); + const toast = useToast(); + const agent = usePDSAgent(); + const did = agent?.did as `did:${string}:${string}` | undefined; + const [settings, setSettings] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!agent || !did) return; + (async () => { + try { + const list = (collection: Collection) => + agent.client.call(place.stream.local.listRecords, { + repo: did, + collection, + }); + const [video, audio] = await Promise.all([list(VIDEO), list(AUDIO)]); + const v = video.records[0]?.value as any; + const codecs = audio.records.map((r) => (r.value as any).codec); + setSettings({ + ...DEFAULTS, + ...(v && { + video: true, + size: String(v.height), + fps: String(v.framerate.numerator / v.framerate.denominator), + bitrate: String(v.bitrate), + }), + aac: codecs.includes("aac"), + opus: codecs.includes("opus"), + }); + } catch (e: any) { + toast.show("Couldn't load renditions", e.message, { variant: "error" }); + } + })(); + }, [agent, did]); + + if (!settings || !agent || !did) { + return null; + } + const set = (patch: Partial) => + setSettings({ ...settings, ...patch }); + const bitrate = parseInt(settings.bitrate, 10); + const valid = !settings.video || bitrate > 0; + + const save = async () => { + setSaving(true); + const createdAt = new Date().toISOString(); + const put = (collection: Collection, rkey: string, record: object) => + agent.client.call(place.stream.local.putRecord, { + repo: did, + collection, + rkey, + record: { $type: collection, ...record, createdAt }, + }); + const del = (collection: Collection, rkey: string) => + agent.client.call(place.stream.local.deleteRecord, { + repo: did, + collection, + rkey, + }); + try { + await (settings.video + ? put(VIDEO, "main", { + codec: "h264", + ...SIZES[settings.size], + framerate: { + numerator: parseInt(settings.fps, 10), + denominator: 1, + }, + bitrate, + }) + : del(VIDEO, "main")); + for (const codec of Object.keys(AUDIO_BITRATES) as AudioCodec[]) { + await (settings[codec] + ? put(AUDIO, codec, { codec, bitrate: AUDIO_BITRATES[codec] }) + : del(AUDIO, codec)); + } + toast.show("Saved", "OBS picks these up the next time it goes live."); + } catch (e: any) { + toast.show("Couldn't save renditions", e.message, { variant: "error" }); + } finally { + setSaving(false); + } + }; + + return ( + + Video + + set({ video })} + testID="desktop-video" + /> + {settings.video && ( + + + + H.264 + + set({ bitrate })} + keyboardType="numeric" + error={valid ? undefined : "Enter a bitrate in kbps"} + testID="desktop-video-bitrate" + /> + + + )} + + Audio + + set({ aac })} + testID="desktop-audio-aac" + /> + set({ opus })} + testID="desktop-audio-opus" + /> + + + + + + ); +} diff --git a/js/app/src/linking-config.ts b/js/app/src/linking-config.ts index 6baa39ce5..b4c24b4e4 100644 --- a/js/app/src/linking-config.ts +++ b/js/app/src/linking-config.ts @@ -143,6 +143,7 @@ export const SCREEN_PATHS = { InfoWidgetEmbed: "info-widget", DanmuOBS: "widgets/:user/danmu", PopoutStreamMonitor: "widgets/stream-monitor", + Desktop: "desktop", PopoutInfoWidget: "widgets/info", PopoutMultistream: "widgets/multistream", PopoutLivestream: "widgets/livestream", @@ -262,6 +263,7 @@ export const streamplaceLinkingOptions: LinkingOptions= breakpoints.lg; + const hideSidebar = useStore((x) => x.setSidebarHidden); + const showSidebar = useStore((x) => x.setSidebarUnhidden); + + useEffect(() => { + hideSidebar(); + return () => showSidebar(); + }, [hideSidebar, showSidebar]); + + return ( + + + + + + + Streamplace Desktop + + + + + + ); +} + +/** + * Streamplace Desktop's home: this node's configuration for the signed-in + * streamer, and a preview of their stream. + */ +export default function Desktop() { + const isReady = useIsReady(); + const userProfile = useUserProfile(); + const openLoginModal = useStore((state) => state.openLoginModal); + + useEffect(() => { + if (isReady && !userProfile) { + openLoginModal({ name: "Desktop" }); + } + }, [isReady, userProfile, openLoginModal]); + + if (!isReady || !userProfile) { + return ; + } + + return ( + + + + + + ); +} diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx index e67b36c61..7de8a329d 100644 --- a/js/app/src/shell.tsx +++ b/js/app/src/shell.tsx @@ -157,6 +157,7 @@ const PopoutMultistream = lazyScreen( const PopoutStreamMonitor = lazyScreen( () => import("src/screens/popout-stream-monitor"), ); +const Desktop = lazyScreen(() => import("src/screens/desktop")); const SearchScreen = lazyScreen(() => import("src/screens/search")); const SupportScreen = lazyScreen(() => import("src/screens/support")); const UploadScreen = lazyScreen(() => import("src/screens/upload")); @@ -844,10 +845,11 @@ export default function Shell() { // scrolls under; the livestream keeps its own solid header. const isVodDetail = currentRouteName === "Video" || currentRouteName === "Vod" || videoPage; - // Embeds and OBS/popout utilities are not app chrome surfaces. + // Embeds, OBS/popout utilities and Streamplace Desktop's screen are not + // app chrome surfaces. const showHeaderSearch = sidebar.isActive && - !/Embed|^Popout|^DanmuOBS$/.test(currentRouteName ?? ""); + !/Embed|^Popout|^DanmuOBS$|^Desktop$/.test(currentRouteName ?? ""); useEffect(() => { setOverlay(isDetailView); }, [isDetailView, setOverlay]); @@ -971,6 +973,11 @@ export default function Shell() { component={PopoutStreamMonitor} options={{ headerShown: false }} /> + -`) and the OBS plugin from the +same commit (`make obs-plugin--`, in `bin/`). Desktop installs +or updates that plugin in OBS (`src/obs.ts`); `pnpm run start` uses the copy the +last package run unpacked into `out/obs-plugin/`. + +## `pnpm test` + +Unit tests for the OBS plugin installer and profile writer. diff --git a/js/desktop/forge.config.ts b/js/desktop/forge.config.ts index 3c1157d5f..7a2697fae 100644 --- a/js/desktop/forge.config.ts +++ b/js/desktop/forge.config.ts @@ -20,10 +20,10 @@ import { rendererConfig } from "./webpack.renderer.config"; export default async function () { // go get the version from the actual script - let version = child_process + const gitVersion = child_process .execSync("go run ../../pkg/config/git/git.go -v") - .toString() - .slice(1); + .toString(); + let version = gitVersion.slice(1); let versionCode = child_process .execSync("go run ../../pkg/config/git/git.go --js") @@ -76,9 +76,39 @@ export default async function () { if (!config.packagerConfig.extraResource) { config.packagerConfig.extraResource = []; } + // The OBS plugin from this same build, unpacked, for src/obs.ts to + // install from. + const target = `${platform}-${architecture}`; + const pluginArchive = `../../bin/obs-streamplace-${gitVersion}-${target}.${platform === "windows" ? "zip" : "tar.gz"}`; + if (!fs.existsSync(pluginArchive)) { + throw new Error( + `could not find ${pluginArchive} while building electron bundle. do you need to run make obs-plugin-${target}?`, + ); + } + const pluginDir = `./out/obs-plugin/${target}`; + fs.rmSync(pluginDir, { recursive: true, force: true }); + fs.mkdirSync(pluginDir, { recursive: true }); + if (platform === "windows") { + child_process.execFileSync("unzip", [ + "-q", + pluginArchive, + "-d", + pluginDir, + ]); + } else { + child_process.execFileSync("tar", [ + "-xzf", + pluginArchive, + "-C", + pluginDir, + ]); + } + const plugin = `${pluginDir}/obs-streamplace${platform === "darwin" ? ".plugin" : ""}`; + config.packagerConfig.extraResource = [ ...config.packagerConfig.extraResource, binary, + plugin, ]; }, readPackageJson: async (forgeConfig, packageJson) => { diff --git a/js/desktop/package.json b/js/desktop/package.json index bf4448620..111d6366b 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -11,6 +11,7 @@ "make": "pnpm --filter @streamplace/brand generate && electron-forge make", "publish": "electron-forge publish", "nolint": "eslint --ext .ts,.tsx .", + "test": "node --require ts-node/register/transpile-only --test src/*.test.ts", "prepare": "CGO_ENABLED=0 go run ../../pkg/config/git/git.go --js > src/version.ts" }, "devDependencies": { diff --git a/js/desktop/src/index.ts b/js/desktop/src/index.ts index 2609b4739..36b2eb5c3 100644 --- a/js/desktop/src/index.ts +++ b/js/desktop/src/index.ts @@ -1,9 +1,10 @@ -import { app, dialog } from "electron"; +import { app, BrowserWindow, dialog, ipcMain } from "electron"; import { parseArgs } from "node:util"; import "source-map-support/register"; import { generatePrivateKey } from "viem/accounts"; import getEnv from "./env"; import makeNode from "./node"; +import { configureStream, installPlugin, status as obsStatus } from "./obs"; import runTests, { allTestNames } from "./tests/test-runner"; import initUpdater from "./updater"; import { makeWindow } from "./window"; @@ -16,7 +17,7 @@ if (require("electron-squirrel-startup")) { options: { path: { type: "string", - default: "", + default: "/desktop", }, "self-test": { type: "boolean", @@ -93,6 +94,14 @@ if (require("electron-squirrel-startup")) { } const mainWindow = await makeWindow(); + // For the OBS setup screen, through SP_ELECTRON.obs in preload.ts. + const server = loadAddr ?? "http://127.0.0.1:38082"; + ipcMain.handle("obs:status", () => obsStatus()); + ipcMain.handle("obs:installPlugin", () => installPlugin()); + ipcMain.handle("obs:configureStream", (_e, streamKey: string) => + configureStream(server, streamKey), + ); + let startPath; if (nodeFrontend) { startPath = `${loadAddr}${args.path}`; @@ -101,6 +110,47 @@ if (require("electron-squirrel-startup")) { } console.log(`opening ${startPath}`); mainWindow.loadURL(startPath); + + offerObsPlugin(mainWindow).catch((e) => + console.error("OBS plugin check failed", e), + ); + }; + + // Offers to install or update the bundled OBS plugin when OBS could load it. + const offerObsPlugin = async (window: BrowserWindow) => { + const { obs, plugin, obsRunning } = await obsStatus(); + if (obs !== "installed" || plugin === "current") { + return; + } + const verb = plugin === "outdated" ? "Update" : "Install"; + const { response } = await dialog.showMessageBox(window, { + type: "question", + buttons: [verb, "Later"], + title: "Streamplace OBS plugin", + message: `${verb} the Streamplace plugin for OBS?`, + detail: + obsRunning && process.platform === "win32" + ? `Close OBS first, then choose ${verb}.` + : "OBS streams to Streamplace through it.", + }); + if (response !== 0) { + return; + } + const { ok, error, restartObs } = await installPlugin(); + if (!ok) { + await dialog.showMessageBox(window, { + type: "error", + title: "Streamplace OBS plugin", + message: "Couldn't install the OBS plugin", + detail: error, + }); + } else if (restartObs) { + await dialog.showMessageBox(window, { + type: "info", + title: "Streamplace OBS plugin", + message: "Restart OBS to load the new plugin.", + }); + } }; // This method will be called when Electron has finished diff --git a/js/desktop/src/obs.test.ts b/js/desktop/src/obs.test.ts new file mode 100644 index 000000000..97aa73551 --- /dev/null +++ b/js/desktop/src/obs.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { installPluginDir, pluginState, writeStreamConfig } from "./obs"; + +// A plugin directory in the Linux/Windows layout package.sh produces. +const makePlugin = async (dir: string, build_time: number) => { + await mkdir(join(dir, "data"), { recursive: true }); + await mkdir(join(dir, "bin", "64bit"), { recursive: true }); + await writeFile( + join(dir, "data", "manifest.json"), + JSON.stringify({ version: `v${build_time}`, build_time }), + ); + await writeFile(join(dir, "bin", "64bit", "obs-streamplace.so"), ""); +}; + +const setup = async () => { + const tmp = await mkdtemp(join(os.tmpdir(), "obs-test-")); + const bundled = join(tmp, "resources", "obs-streamplace"); + await makePlugin(bundled, 2); + return { bundled, root: join(tmp, "plugins") }; +}; + +test("installs into an empty plugin root", async () => { + const { bundled, root } = await setup(); + assert.equal((await pluginState(bundled, root)).plugin, "absent"); + assert.equal(await installPluginDir(bundled, root), true); + assert.deepEqual(await pluginState(bundled, root), { + plugin: "current", + bundledVersion: "v2", + installedVersion: "v2", + }); + assert.deepEqual(await readdir(root), ["obs-streamplace"]); +}); + +test("replaces an outdated plugin", async () => { + const { bundled, root } = await setup(); + const installed = join(root, "obs-streamplace"); + await makePlugin(installed, 1); + await writeFile(join(installed, "stale"), ""); + assert.equal((await pluginState(bundled, root)).plugin, "outdated"); + assert.equal(await installPluginDir(bundled, root), true); + assert.equal((await pluginState(bundled, root)).plugin, "current"); + assert.equal(existsSync(join(installed, "stale")), false); + assert.deepEqual(await readdir(root), ["obs-streamplace"]); +}); + +test("treats a plugin without a manifest as outdated", async () => { + const { bundled, root } = await setup(); + await mkdir(join(root, "obs-streamplace", "bin"), { recursive: true }); + assert.deepEqual(await pluginState(bundled, root), { + plugin: "outdated", + bundledVersion: "v2", + installedVersion: undefined, + }); +}); + +test("leaves a current plugin alone", async () => { + const { bundled, root } = await setup(); + const installed = join(root, "obs-streamplace"); + await makePlugin(installed, 2); + await writeFile(join(installed, "marker"), ""); + assert.equal(await installPluginDir(bundled, root), false); + assert.equal(existsSync(join(installed, "marker")), true); +}); + +const makeProfiles = async (ini: string, iniBody: string) => { + const config = await mkdtemp(join(os.tmpdir(), "obs-config-")); + for (const name of ["Mine", "Other"]) { + await mkdir(join(config, "basic", "profiles", name), { recursive: true }); + await writeFile( + join(config, "basic", "profiles", name, "basic.ini"), + `[General]\nName=${name}\n`, + ); + } + await writeFile( + join(config, "basic", "profiles", "Mine", "service.json"), + '{"type":"rtmp_custom","settings":{"server":"rtmp://old","key":"old"}}', + ); + await writeFile(join(config, ini), iniBody); + return config; +}; + +const service = async (config: string, profile: string) => + JSON.parse( + await readFile( + join(config, "basic", "profiles", profile, "service.json"), + "utf8", + ), + ); + +test("points the current profile at the node", async () => { + // OBS 31+ keeps the current profile in user.ini, written with a BOM and CRLF. + const config = await makeProfiles( + "user.ini", + "\uFEFF[General]\r\nProfileDir=Other\r\n[Basic]\r\nProfile=Mine\r\nProfileDir=Mine\r\n", + ); + await writeStreamConfig(config, "http://127.0.0.1:38082", "sk"); + assert.deepEqual(await service(config, "Mine"), { + type: "rtmp_common", + settings: { + service: "Streamplace", + server: "http://127.0.0.1:38082", + key: "sk", + }, + }); + assert.equal( + await readFile( + join(config, "basic", "profiles", "Mine", "basic.ini"), + "utf8", + ), + "[General]\nName=Mine\n", + ); + assert.equal( + existsSync(join(config, "basic", "profiles", "Other", "service.json")), + false, + ); +}); + +test("reads the current profile from global.ini before OBS 31", async () => { + const config = await makeProfiles( + "global.ini", + "[Basic]\nProfileDir=Other\n", + ); + await writeStreamConfig(config, "http://127.0.0.1:38082", "sk"); + assert.equal((await service(config, "Other")).settings.key, "sk"); +}); + +test("refuses without a profile", async () => { + const config = await mkdtemp(join(os.tmpdir(), "obs-config-")); + await assert.rejects( + writeStreamConfig(config, "http://x", "sk"), + /no profile/, + ); +}); diff --git a/js/desktop/src/obs.ts b/js/desktop/src/obs.ts new file mode 100644 index 000000000..49836a5e3 --- /dev/null +++ b/js/desktop/src/obs.ts @@ -0,0 +1,301 @@ +import { execFile } from "child_process"; +import { + access, + cp, + mkdir, + mkdtemp, + readFile, + rename, + rm, + writeFile, +} from "fs/promises"; +import os from "os"; +import { basename, delimiter, join, resolve } from "path"; +import { promisify } from "util"; +import getEnv from "./env"; + +const run = promisify(execFile); + +export type ObsStatus = { + obs: "absent" | "installed" | "flatpak"; + plugin: "absent" | "outdated" | "current"; + obsRunning: boolean; + bundledVersion: string; + installedVersion?: string; +}; + +// Flatpak OBS only loads plugins packaged as Flatpak extensions. +const FLATPAK_ERROR = + "OBS is installed as a Flatpak, which can't load the Streamplace plugin. Install OBS from your distribution or obsproject.com instead."; + +const exists = (path: string) => + access(path).then( + () => true, + () => false, + ); + +// Where OBS keeps its settings and profiles. +const obsConfigDir = () => { + const home = os.homedir(); + if (process.platform === "win32") { + return join( + process.env.APPDATA ?? join(home, "AppData", "Roaming"), + "obs-studio", + ); + } + if (process.platform === "darwin") { + return join(home, "Library", "Application Support", "obs-studio"); + } + return join( + process.env.XDG_CONFIG_HOME || join(home, ".config"), + "obs-studio", + ); +}; + +// Where OBS 30.2+ loads third-party plugins from; machine-wide on Windows. +const pluginRoot = () => { + if (process.platform === "win32") { + return join( + process.env.ProgramData ?? "C:\\ProgramData", + "obs-studio", + "plugins", + ); + } + return join(obsConfigDir(), "plugins"); +}; + +// The plugin forge.config.ts unpacks into the app's resources. +const bundledPlugin = () => { + const name = + process.platform === "darwin" + ? "obs-streamplace.plugin" + : "obs-streamplace"; + if (getEnv().isDev) { + const platform = + process.platform === "win32" ? "windows" : process.platform; + const arch = process.arch === "x64" ? "amd64" : process.arch; + return resolve( + process.cwd(), + "out", + "obs-plugin", + `${platform}-${arch}`, + name, + ); + } + return resolve(process.resourcesPath, name); +}; + +// Written by obs-plugin/package.sh. +const readManifest = async ( + plugin: string, +): Promise<{ version: string; build_time: number } | undefined> => { + const file = plugin.endsWith(".plugin") + ? join(plugin, "Contents", "Resources", "manifest.json") + : join(plugin, "data", "manifest.json"); + try { + return JSON.parse(await readFile(file, "utf8")); + } catch { + return undefined; + } +}; + +// How the plugin installed under root compares to the bundled one. A copy +// without a manifest predates them, so it's outdated. +export const pluginState = async (bundled: string, root: string) => { + const want = await readManifest(bundled); + if (!want) { + throw new Error(`no OBS plugin bundled at ${bundled}`); + } + const installed = join(root, basename(bundled)); + if (!(await exists(installed))) { + return { plugin: "absent" as const, bundledVersion: want.version }; + } + const have = await readManifest(installed); + return { + plugin: + have && have.build_time >= want.build_time + ? ("current" as const) + : ("outdated" as const), + bundledVersion: want.version, + installedVersion: have?.version, + }; +}; + +// Copies the bundled plugin into root unless it's already current, replacing +// an older copy. Returns whether it installed anything. +export const installPluginDir = async ( + bundled: string, + root: string, +): Promise => { + if ((await pluginState(bundled, root)).plugin === "current") { + return false; + } + await mkdir(root, { recursive: true }); + // Staged inside root, so swapping it in is a rename on one filesystem. OBS + // doesn't load from here: the module sits one directory too deep. + const stage = await mkdtemp(join(root, ".obs-streamplace-")); + try { + const target = join(root, basename(bundled)); + await cp(bundled, join(stage, "new"), { recursive: true }); + // On Windows this fails while OBS has the DLL loaded, before anything changes. + await rename(target, join(stage, "old")).catch((e) => { + if (e.code !== "ENOENT") throw e; + }); + await rename(join(stage, "new"), target); + } finally { + await rm(stage, { recursive: true, force: true }); + } + return true; +}; + +// The profile OBS opens with: user.ini since OBS 31, global.ini before. +const currentProfileDir = async (configDir: string) => { + for (const file of ["user.ini", "global.ini"]) { + const text = await readFile(join(configDir, file), "utf8").catch(() => ""); + let section = ""; + for (const line of text.replace(/^\uFEFF/, "").split(/\r?\n/)) { + const l = line.trim(); + if (l.startsWith("[")) { + section = l; + } else if (section === "[Basic]" && l.startsWith("ProfileDir=")) { + return l.slice("ProfileDir=".length); + } + } + } + return undefined; +}; + +// Points OBS's current profile at server with this stream key, leaving the +// profile's other settings alone. +export const writeStreamConfig = async ( + configDir: string, + server: string, + key: string, +) => { + const profile = await currentProfileDir(configDir); + if (!profile) { + throw new Error( + "OBS has no profile yet. Open OBS once, close it, and try again.", + ); + } + const service = { + type: "rtmp_common", + settings: { service: "Streamplace", server, key }, + }; + await writeFile( + join(configDir, "basic", "profiles", profile, "service.json"), + JSON.stringify(service, null, 4), + ); +}; + +const detectObs = async (): Promise => { + if (process.platform === "win32") { + if ( + await exists( + join(process.env.ProgramFiles ?? "C:\\Program Files", "obs-studio"), + ) + ) { + return "installed"; + } + return run( + "reg", + [ + "query", + "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\OBS Studio", + ], + { windowsHide: true }, + ).then( + () => "installed", + () => "absent", + ); + } + if (process.platform === "darwin") { + return (await exists("/Applications/OBS.app")) ? "installed" : "absent"; + } + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + if (dir && (await exists(join(dir, "obs")))) { + return "installed"; + } + } + if ( + await exists(join(os.homedir(), ".var", "app", "com.obsproject.Studio")) + ) { + return "flatpak"; + } + return (await exists(obsConfigDir())) ? "installed" : "absent"; +}; + +const isObsRunning = async () => { + if (process.platform === "win32") { + const { stdout } = await run( + "tasklist", + ["/FI", "IMAGENAME eq obs64.exe", "/NH"], + { windowsHide: true }, + ); + return stdout.includes("obs64.exe"); + } + return run("pgrep", [ + "-x", + process.platform === "darwin" ? "OBS" : "obs", + ]).then( + () => true, + () => false, + ); +}; + +export const status = async (): Promise => { + const [obs, obsRunning, state] = await Promise.all([ + detectObs(), + isObsRunning(), + pluginState(bundledPlugin(), pluginRoot()), + ]); + return { obs, obsRunning, ...state }; +}; + +export const installPlugin = async (): Promise<{ + ok: boolean; + error?: string; + restartObs: boolean; +}> => { + const [obs, obsRunning] = await Promise.all([detectObs(), isObsRunning()]); + if (obs !== "installed") { + const error = obs === "flatpak" ? FLATPAK_ERROR : "OBS isn't installed."; + return { ok: false, error, restartObs: false }; + } + if (obsRunning && process.platform === "win32") { + return { + ok: false, + error: "Close OBS, then install the plugin.", + restartObs: false, + }; + } + try { + const installed = await installPluginDir(bundledPlugin(), pluginRoot()); + return { ok: true, restartObs: installed && obsRunning }; + } catch (e) { + return { ok: false, error: e.message, restartObs: false }; + } +}; + +export const configureStream = async ( + server: string, + streamKey: string, +): Promise<{ ok: boolean; error?: string }> => { + const [obs, obsRunning] = await Promise.all([detectObs(), isObsRunning()]); + if (obs !== "installed") { + return { + ok: false, + error: obs === "flatpak" ? FLATPAK_ERROR : "OBS isn't installed.", + }; + } + // OBS writes its settings back out when it exits, undoing ours. + if (obsRunning) { + return { ok: false, error: "Close OBS, then set up the stream." }; + } + try { + await writeStreamConfig(obsConfigDir(), server, streamKey); + return { ok: true }; + } catch (e) { + return { ok: false, error: e.message }; + } +}; diff --git a/js/desktop/src/preload.ts b/js/desktop/src/preload.ts index a02a09595..dc366e742 100644 --- a/js/desktop/src/preload.ts +++ b/js/desktop/src/preload.ts @@ -1,5 +1,12 @@ -import { contextBridge } from "electron"; +import { contextBridge, ipcRenderer } from "electron"; +// Handled in index.ts; see obs.ts for the shapes. contextBridge.exposeInMainWorld("SP_ELECTRON", { isElectron: true, + obs: { + status: () => ipcRenderer.invoke("obs:status"), + installPlugin: () => ipcRenderer.invoke("obs:installPlugin"), + configureStream: (streamKey: string) => + ipcRenderer.invoke("obs:configureStream", streamKey), + }, }); diff --git a/js/e2e-web/flows/desktop.spec.ts b/js/e2e-web/flows/desktop.spec.ts new file mode 100644 index 000000000..0f28eb9d2 --- /dev/null +++ b/js/e2e-web/flows/desktop.spec.ts @@ -0,0 +1,95 @@ +import { expect, test, type Page } from "@playwright/test"; +import { loginThroughPds } from "./login"; + +// Streamplace Desktop's screen (/desktop) keeps the renditions OBS encodes as +// records on the node (place.stream.local.*), and the node hands them to the +// OBS plugin when it asks how to stream (/api/ingest/client-configuration, +// the request the plugin sends with the stream key). The plugin's own use of +// the answer is C (obs-plugin/); the OBS card needs Electron and is covered by +// js/desktop's tests. +const HTTPS_URL = process.env.SERVER_HTTPS_URL; +const SERVER_URL = process.env.SERVER_URL!; +const STREAM_KEY = process.env.STREAM_KEY!; + +test.skip(!HTTPS_URL, "harness started without its HTTPS hostnames"); + +async function toggle(page: Page, testId: string, on: boolean) { + const row = page.getByTestId(testId); + if ((await row.getAttribute("aria-checked")) !== String(on)) { + await row.click(); + } + await expect(row).toHaveAttribute("aria-checked", String(on)); +} + +async function save(page: Page) { + const saved = page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith( + "/xrpc/place.stream.local.deleteRecord", + ), + ); + await page.getByTestId("desktop-save").click(); + expect((await saved).ok()).toBe(true); + await expect(page.getByText("Saved").first()).toBeVisible(); +} + +async function clientConfiguration(page: Page) { + const response = await page.request.post( + `${SERVER_URL}/api/ingest/client-configuration`, + { data: { authentication: STREAM_KEY } }, + ); + expect(response.ok()).toBe(true); + return response.json(); +} + +test("desktop: OBS encodes the renditions set on the node", async ({ + page, +}) => { + await loginThroughPds(page); + await page.goto(`${HTTPS_URL}/desktop`); + await expect(page.getByTestId("desktop-video")).toBeVisible({ + timeout: 30_000, + }); + + await toggle(page, "desktop-video", true); + await page.getByText("30 fps").click(); + await page.getByTestId("desktop-video-bitrate").fill("4500"); + await toggle(page, "desktop-audio-aac", true); + await toggle(page, "desktop-audio-opus", false); + await save(page); + + const config = await clientConfiguration(page); + expect(config.status.result).toBe("success"); + expect(config.encoder_configurations).toEqual([ + { + codec: "h264", + width: 1920, + height: 1080, + framerate: { numerator: 30, denominator: 1 }, + settings: { bitrate: 4500, rate_control: "CBR", keyint_sec: 1, bf: 0 }, + }, + ]); + expect(config.audio_configurations.live).toEqual([ + { codec: "aac", track_id: 0, channels: 2, settings: { bitrate: 160 } }, + ]); + + // Kept on the node, so they come back after a reload. + await page.reload(); + await expect(page.getByTestId("desktop-video")).toHaveAttribute( + "aria-checked", + "true", + { timeout: 30_000 }, + ); + await expect(page.getByTestId("desktop-video-bitrate")).toHaveValue("4500"); + await expect(page.getByTestId("desktop-audio-opus")).toHaveAttribute( + "aria-checked", + "false", + ); + + // Turned off, OBS keeps its own encoders again. + await toggle(page, "desktop-video", false); + await toggle(page, "desktop-audio-aac", false); + await save(page); + const cleared = await clientConfiguration(page); + expect(cleared.encoder_configurations).toBeUndefined(); + expect(cleared.audio_configurations).toBeUndefined(); +}); diff --git a/obs-plugin/package.sh b/obs-plugin/package.sh index bde7b6ee4..be7e9b009 100755 --- a/obs-plugin/package.sh +++ b/obs-plugin/package.sh @@ -14,23 +14,29 @@ NAME=obs-streamplace BUILD="$(dirname "$0")/build-$TARGET" STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT +# Streamplace Desktop reads this to tell whether an installed copy is older +# than the one it bundles; build_time matches pkg/config/git's BuildTime. +MANIFEST="{\"id\":\"place.stream.$NAME\",\"name\":\"$NAME\",\"version\":\"$VERSION\",\"build_time\":$(git log -1 --format=%at)}" case "$TARGET" in linux-*) - mkdir -p "$STAGE/$NAME/bin/64bit" + mkdir -p "$STAGE/$NAME/bin/64bit" "$STAGE/$NAME/data" cp "$BUILD/$NAME.so" "$STAGE/$NAME/bin/64bit/" + echo "$MANIFEST" >"$STAGE/$NAME/data/manifest.json" tar -czf "$OUT/$NAME-$VERSION-$TARGET.tar.gz" -C "$STAGE" "$NAME" ;; windows-*) - mkdir -p "$STAGE/$NAME/bin/64bit" + mkdir -p "$STAGE/$NAME/bin/64bit" "$STAGE/$NAME/data" cp "$BUILD/$NAME.dll" "$STAGE/$NAME/bin/64bit/" + echo "$MANIFEST" >"$STAGE/$NAME/data/manifest.json" rm -f "$OUT/$NAME-$VERSION-$TARGET.zip" (cd "$STAGE" && zip -qr "$OUT/$NAME-$VERSION-$TARGET.zip" "$NAME") ;; darwin-*) BUNDLE="$STAGE/$NAME.plugin/Contents" - mkdir -p "$BUNDLE/MacOS" + mkdir -p "$BUNDLE/MacOS" "$BUNDLE/Resources" cp "$BUILD/$NAME.dylib" "$BUNDLE/MacOS/$NAME" + echo "$MANIFEST" >"$BUNDLE/Resources/manifest.json" cat >"$BUNDLE/Info.plist" < diff --git a/package.json b/package.json index c5ba74973..8c43e3b06 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "preinstall": "node hack/node-version.js", "check": "knip && pnpm run lint && pnpm run test:eslint && pnpm run check:workspaces && git ls-files | xargs prettier --check --ignore-unknown", - "check:workspaces": "pnpm --filter @streamplace/components test && cd js/app && pnpm run check && pnpm --filter @streamplace/e2e-web run typecheck", + "check:workspaces": "pnpm --filter @streamplace/components test && pnpm --filter streamplace-desktop test && cd js/app && pnpm run check && pnpm --filter @streamplace/e2e-web run typecheck", "lint": "eslint --no-warn-ignored \"js/app/src/**/*.{ts,tsx}\" \"js/app/components/**/*.{ts,tsx}\" \"js/app/hooks/**/*.{ts,tsx}\" \"js/components/src/**/*.{ts,tsx}\"", "test:eslint": "node --test \"js/scripts/eslint/*.test.mjs\"", "fix": "git ls-files | xargs prettier --write --ignore-unknown", -- 2.54.0