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",