diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 182782ca9..8dd1669e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,6 +108,7 @@ jobs: working-directory: ./desktop env: TEST_APPLICATION_PATH: ${{ runner.temp }}\badger\Badger Desktop.exe + ELECTRON: "true" linear: needs: [test-e2e-server, test-desktop] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2870103e4..f95c8e4a4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -178,7 +178,7 @@ jobs: PLAYWRIGHT_HTML_REPORT: ${{ github.workspace }}/server/playwright-report - uses: actions/upload-artifact@v3 - if: always() + if: failure() with: name: playwright-report-server path: ./server/playwright-report/ @@ -187,10 +187,13 @@ jobs: test-e2e-desktop-standalone: timeout-minutes: 60 runs-on: ubuntu-latest + strategy: + matrix: + ELECTRON: [true, false] env: NODE_ENV: test E2E_TEST: "true" - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: ${{ matrix.ELECTRON == 'true' && 1 || 0 }} steps: - uses: actions/checkout@v4 - name: Use Node.js 18.x @@ -210,15 +213,20 @@ jobs: - name: Make logs folder run: mkdir -p ${{ runner.temp }}/logs + - uses: ./.github/steps/setup-playwright + with: + working-directory: ./desktop + - name: Run Playwright tests run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn ${{ runner.debug && 'test:e2e:debug' || 'test:e2e' }} --project standalone working-directory: ./desktop env: PLAYWRIGHT_HTML_REPORT: ${{ github.workspace }}/desktop/playwright-report BADGER_LOGS_PATH: ${{ runner.temp }}/logs + ELECTRON: ${{ matrix.ELECTRON }} - uses: actions/upload-artifact@v3 - if: always() + if: failure() with: name: playwright-report-desktop path: | @@ -297,9 +305,10 @@ jobs: env: PLAYWRIGHT_HTML_REPORT: ${{ github.workspace }}/desktop/playwright-report BADGER_LOGS_PATH: ${{ runner.temp }}/logs + ELECTRON: "true" - uses: actions/upload-artifact@v3 - if: always() + if: failure() with: name: playwright-report-desktop path: | diff --git a/.mise-tasks/build/desktop b/.mise-tasks/build/desktop new file mode 100755 index 000000000..6494bec0b --- /dev/null +++ b/.mise-tasks/build/desktop @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +#MISE dir="{{config_root}}/desktop" +#MISE sources=["{{config_root}}/desktop/src/**/*", "{{config_root}}/desktop/*.cjs", "{{config_root}}/desktop/*.mjs", "{{config_root}}/desktop/*.ts", "{{config_root}}/desktop/*.mts", "{{config_root}}/desktop/package.json"] +#MISE outputs=["{{config_root}}/desktop/out/**/*"] +rm -rf out +node ../node_modules/.bin/electron-vite build diff --git a/.mise-tasks/test/desktop/e2e/standalone b/.mise-tasks/test/desktop/e2e/standalone new file mode 100755 index 000000000..d49fc549a --- /dev/null +++ b/.mise-tasks/test/desktop/e2e/standalone @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +#MISE env={E2E_TEST = "true"} +#MISE dir="{{config_root}}/desktop" +#MISE depends="build:desktop" +#USAGE flag "--headed" help="Run tests in headed mode" +#USAGE flag "-d --debug-webserver" help="Run tests in debug mode" +#USAGE flag "-e --electron" help="Run tests in Electron" +playwright_args=() +if [ -n "$usage_headed" ]; then + playwright_args+=("--headed") +fi +if [ -n "$usage_electron" ]; then + export ELECTRON=true +fi +if [ -n "$usage_debug" ]; then + export DEBUG='pw:api*,pw:browser*' +fi +export E2E_TEST=true +npx playwright test --project standalone "${playwright_args[@]}" diff --git a/Dangerfile.ts b/Dangerfile.ts index a9f5e5710..9697c355e 100644 --- a/Dangerfile.ts +++ b/Dangerfile.ts @@ -1,6 +1,6 @@ import { message, danger, fail, warn } from "danger"; -const issueKeyRe = /(BDGR-\d+)/g; +const issueKeyRe = /(BA?DGE?R-\d+)/g; async function findAddedAndRemovedTodoIssues() { const removed = new Set(); @@ -41,6 +41,12 @@ async function findAddedAndRemovedTodoIssues() { } } } + for (const rm of removed) { + if (added.has(rm)) { + removed.delete(rm); + added.delete(rm); + } + } return { removed, added, linesWithoutKey, fixmes }; } @@ -75,7 +81,11 @@ You can also include \`Closes ${Array.from(removed).join( } if (fixmes.size > 0) { fail( - `Found ${fixmes.size} FIXME comments. Please either remove them or convert them to TODOs (with an associated Linear ticket).`, + `Found ${fixmes.size} FIXME comments. Please either remove them or convert them to TODOs (with an associated Linear ticket): \n${Array.from( + fixmes, + ) + .map((l) => " * `" + l + "`") + .join("\n")}`, ); } }; diff --git a/desktop/.env b/desktop/.env index 473092aaf..5f9abb6e5 100644 --- a/desktop/.env +++ b/desktop/.env @@ -1,4 +1,5 @@ -DESKTOP_SENTRY_DSN=https://123456@example-dsn-replace-this-in-production.test/789x` +DESKTOP_SENTRY_DSN=https://123456@example-dsn-replace-this-in-production.test/789x VITE_DESKTOP_SENTRY_DSN=$DESKTOP_SENTRY_DSN ENVIRONMENT=localhost VITE_ENVIRONMENT=$ENVIRONMENT +BADGER_ENABLE_REDUX_DEVTOOLS=true diff --git a/desktop/.gitignore b/desktop/.gitignore index 3431461d0..88b1214d1 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -99,3 +99,6 @@ dist/ # Bundle visualization bundle-*.html + +# Settings +.dev/ diff --git a/desktop/e2e/standalone/base.ts b/desktop/e2e/standalone/base.ts index 53bdcb568..15511c225 100644 --- a/desktop/e2e/standalone/base.ts +++ b/desktop/e2e/standalone/base.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-empty-pattern */ /* eslint-disable no-console */ import { test as base, @@ -17,15 +18,15 @@ const MICRO_SERVER_PORT = process.env.MICRO_SERVER_PORT : 8594; const MICRO_SERVER_PASSWORD = "microserver"; -const test = base.extend<{ +const ELECTRON = Boolean(process.env.TEST_USE_ELECTRON); + +let test = base.extend<{ scenario: string; enabledIntegrations: ("obs" | "ontime" | "vmix")[]; testMediaPath: string; - app: [ElectronApplication, Page]; }>({ scenario: "default", enabledIntegrations: ["obs", "ontime", "vmix"], - // eslint-disable-next-line no-empty-pattern testMediaPath: async ({}, use) => { const dir = await fsp.mkdtemp( join(tmpdir(), "badger-desktop-e2e-standalone"), @@ -33,55 +34,106 @@ const test = base.extend<{ await use(dir); await fsp.rm(dir, { recursive: true }); }, - - app: async ({ scenario, testMediaPath }, use, testInfo) => { - // Allow running tests on a built / installed app - const electronPath = process.env.TEST_APPLICATION_PATH; - const app = await electron.launch({ - args: electronPath - ? ["--enable-logging"] - : ["--enable-logging", "out/main/index.js"], - executablePath: electronPath, - env: { - ...process.env, - NODE_ENV: "test", - E2E_TEST: "true", - __USE_MOCK_VMIX: "true", - __TEST_SETTINGS_MEDIA: `{ "mediaPath": ${JSON.stringify(testMediaPath)} }`, - __TEST_SUPPORTED_INTEGRATIONS: JSON.stringify([ - "obs", - "ontime", - "vmix", - ]), - }, - }); - - const win = await app.firstWindow(); - - await win.context().tracing.start({ screenshots: true, snapshots: true }); - - await win.waitForLoadState("domcontentloaded"); - - await win - .getByLabel("Server address") - .fill(`http://localhost:${MICRO_SERVER_PORT}/${scenario}`); - await win.getByLabel("Server Password").fill(MICRO_SERVER_PASSWORD); - - await win.getByRole("button", { name: "Connect" }).click(); - - await expect( - win.getByRole("heading", { name: "Select a show" }), - ).toBeVisible(); - - await use([app, win]); - - await win - .context() - .tracing.stop({ path: `traces/${testInfo.title}-${testInfo.retry}.zip` }); - - await win.close(); - await app.close(); - }, }); +if (ELECTRON) { + test = test.extend<{ app: [ElectronApplication, Page]; page: Page }>({ + app: async ({ scenario, testMediaPath }, use, testInfo) => { + // Allow running tests on a built / installed app + const electronPath = process.env.TEST_APPLICATION_PATH; + const app = await electron.launch({ + args: electronPath + ? ["--enable-logging"] + : ["--enable-logging", "out/main/index.js"], + executablePath: electronPath, + env: { + ...process.env, + NODE_ENV: "test", + E2E_TEST: "true", + __USE_MOCK_VMIX: "true", + __TEST_SETTINGS_MEDIA: `{ "mediaPath": ${JSON.stringify(testMediaPath)} }`, + __TEST_SUPPORTED_INTEGRATIONS: JSON.stringify([ + "obs", + "ontime", + "vmix", + ]), + }, + }); + + const win = await app.firstWindow(); + + await win.context().tracing.start({ screenshots: true, snapshots: true }); + + await win.waitForLoadState("domcontentloaded"); + + await win + .getByLabel("Server address") + .fill(`http://localhost:${MICRO_SERVER_PORT}/${scenario}`); + await win.getByLabel("Server Password").fill(MICRO_SERVER_PASSWORD); + + await win.getByRole("button", { name: "Connect" }).click(); + + await expect( + win.getByRole("heading", { name: "Select a show" }), + ).toBeVisible(); + + await use([app, win]); + + await win.context().tracing.stop({ + path: `traces/${testInfo.title}-${testInfo.retry}.zip`, + }); + + await win.close(); + await app.close(); + }, + page: async ({ app }, use) => { + const [_, page] = app; + await use(page); + }, + }); +} else { + test = test.extend<{ app: [ElectronApplication, Page]; page: Page }>({ + app: async ({}, _use, testInfo) => { + testInfo.skip(true, "Not running in Electron"); + }, + page: async ({ browser, scenario, request, testMediaPath }, use) => { + await request.post("http://localhost:5174/reset", { + data: { + settings: { + media: { + mediaPath: testMediaPath, + }, + }, + integrations: { + supported: ["obs", "ontime", "vmix"], + }, + }, + }); + + const page = await browser.newPage({ + baseURL: `http://localhost:5173`, + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, + }); + await page.goto("/"); + + await page.waitForLoadState("domcontentloaded"); + + await page + .getByLabel("Server address") + .fill(`http://localhost:${MICRO_SERVER_PORT}/${scenario}`); + await page.getByLabel("Server Password").fill(MICRO_SERVER_PASSWORD); + + await page.getByRole("button", { name: "Connect" }).click(); + + await expect( + page.getByRole("heading", { name: "Select a show" }), + ).toBeVisible(); + + await use(page); + await page.close(); + }, + }); +} + export { test, expect }; diff --git a/desktop/e2e/standalone/obs.spec.ts b/desktop/e2e/standalone/obs.spec.ts index ecdc15ff7..e63e12df6 100644 --- a/desktop/e2e/standalone/obs.spec.ts +++ b/desktop/e2e/standalone/obs.spec.ts @@ -1,9 +1,7 @@ import MockOBSWebSocket from "@badger/testing/MockOBSWebSocket"; import { test, expect } from "./base"; -test("download continuity media and load into OBS", async ({ - app: [_, page], -}) => { +test("download continuity media and load into OBS", async ({ page }) => { const mows = await MockOBSWebSocket.create(expect, async (obs) => { obs.alwaysRespond("GetVersion", () => ({ success: true, diff --git a/desktop/e2e/standalone/standalone.spec.ts b/desktop/e2e/standalone/standalone.spec.ts index ff55babf5..ec48e1997 100644 --- a/desktop/e2e/standalone/standalone.spec.ts +++ b/desktop/e2e/standalone/standalone.spec.ts @@ -1,24 +1,26 @@ import { test, expect } from "./base"; -test("it works", async ({ app: [_, page] }) => { +test("it works", async ({ page }) => { await page.getByText("Test show").click(); }); test.describe("big show", () => { test.use({ scenario: "big-show" }); - test("scrolling for a show with lots of rundown items", async ({ - app: [_, page], - }) => { - await page.getByRole("button", { name: "Select" }).click(); + // TODO[BADGER-180]: Need new vmix mocks in place + test.fixme( + "scrolling for a show with lots of rundown items", + async ({ page }) => { + await page.getByRole("button", { name: "Select" }).click(); - await page.getByText("Continuity").click(); - await page.getByRole("menuitem", { name: "Test Rundown" }).click(); + await page.getByText("Continuity").click(); + await page.getByRole("menuitem", { name: "Test Rundown" }).click(); - await page - .getByRole("cell", { name: "Test Item 40" }) - .scrollIntoViewIfNeeded(); - await expect( - page.getByRole("cell", { name: "Test Item 40" }), - ).toBeInViewport(); - }); + await page + .getByRole("cell", { name: "Test Item 40" }) + .scrollIntoViewIfNeeded(); + await expect( + page.getByRole("cell", { name: "Test Item 40" }), + ).toBeInViewport(); + }, + ); }); diff --git a/desktop/electron.vite.config.mjs b/desktop/electron.vite.config.mjs index c325fdd20..3e6323b37 100644 --- a/desktop/electron.vite.config.mjs +++ b/desktop/electron.vite.config.mjs @@ -1,21 +1,11 @@ import commonjs from "vite-plugin-commonjs"; -import * as fs from "node:fs"; -import { execFileSync } from "node:child_process"; -import { sentryVitePlugin } from "@sentry/vite-plugin"; import { mergeConfig, defineConfig } from "vite"; import { visualizer } from "rollup-plugin-visualizer"; -import ignore from "rollup-plugin-ignore"; -const packageJSON = JSON.parse(fs.readFileSync("./package.json", "utf-8")); -const gitCommit = - process.env.GIT_REV ?? - execFileSync("git", ["rev-parse", "HEAD"]).toString().trim(); -const sentryRelease = - "badger-desktop@" + packageJSON.version + "-" + gitCommit.slice(0, 7); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { base } = require("./vite.config.mjs"); -const prod = process.env.ENVIRONMENT === "prod"; - -const visualizeBundle = process.argv.includes("--visualize-bundle"); +const visualizeBundle = process.env.VISUALIZE_BUNDLE === "true"; /* * Explanation of this gross hack: @@ -24,7 +14,8 @@ const visualizeBundle = process.argv.includes("--visualize-bundle"); * not a problem because of zod-prisma-types, which generates @badger/prisma/types, which we * can import (and forbid importing @badger/prisma/client). * However, zod-prisma-types still needs to import the actual Prisma client in one place, - * transformJsonNull.ts, so that it can access Prisma.JsonNull/Prisma.DbNull. + * transformJsonNull.ts, so that it can access Prisma.JsonNull/Prisma.DbNull. Luckily + * we never use those types in Desktop. * * To fix this, we stub out this one import, which thereby ensures the Prisma client runtime * never gets bundled in. This is safe to do, because we will never need to interact with @@ -53,59 +44,6 @@ const IgnorePrismaJsonNullPlugin = { enforce: "pre", }; -const base = defineConfig({ - define: { - "global.__APP_VERSION__": JSON.stringify(packageJSON.version), - "global.__BUILD_TIME__": JSON.stringify(new Date().toISOString()), - "global.__GIT_COMMIT__": JSON.stringify(gitCommit), - "global.__SENTRY_RELEASE__": JSON.stringify(sentryRelease), - "global.__ENVIRONMENT__": JSON.stringify(process.env.ENVIRONMENT), - }, - plugins: [ - sentryVitePlugin({ - org: "ystv", - project: "badger-desktop", - authToken: process.env.SENTRY_AUTH_TOKEN, - release: { - name: sentryRelease, - }, - disable: process.env.IS_YSTV_BUILD !== "true", - }), - ], - build: { - minify: prod ? "esbuild" : false, - rollupOptions: { - onwarn(warning, warn) { - if (warning.code === "MODULE_LEVEL_DIRECTIVE") { - return; - } - warn(warning); - }, - onLog(level, log, handler) { - if ( - log.cause && - log.cause.message === `Can't resolve original location of error.` - ) { - return; - } - if ( - log.cause && - log.cause.message.startsWith( - `Use of eval in "../utility/prisma/client/runtime/library.js" is strongly discouraged`, - ) - ) { - return; - } - handler(level, log); - }, - external: [ - // Don't bundle Prisma into Desktop - /prisma\/client\/runtime/, - ], - }, - }, -}); - /** * @type {import('electron-vite').UserConfig} */ diff --git a/desktop/mise.toml b/desktop/mise.toml new file mode 100644 index 000000000..6e2069024 --- /dev/null +++ b/desktop/mise.toml @@ -0,0 +1,2 @@ +[env] +_.path = ['./node_modules/.bin'] diff --git a/desktop/package.json b/desktop/package.json index f313ba212..2512fa741 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,17 +6,19 @@ "main": "./out/main/index.js", "scripts": { "start": "electron-vite dev", + "devElectron": "electron-vite dev", + "devServer": "vite-node src/main/devServer.ts", + "devRenderer": "vite dev", + "devBrowser": "concurrently --names main,rndr -c blue,pink \"yarn devServer\" \"yarn devRenderer --open\"", + "dev": "yarn devBrowser", + "devBrowser:noOpen": "concurrently --names main,rndr -c blue,pink \"yarn devServer\" \"yarn devRenderer\"", "build": "rimraf out && electron-vite build", "package": "rimraf dist && yarn build && electron-builder build -c electron-builder.config.cjs", "lint": "eslint src/", "prettify": "prettier --write .", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:e2e": "E2E_TEST=true playwright test", - "test:e2e:debug": "E2E_TEST=true DEBUG='pw:browser*,mows*' playwright test", - "test:e2e:ui": "E2E_TEST=true playwright test --ui", - "microserver": "tsx microserver/index.ts", - "microserver:watch": "tsx --watch microserver/index.ts" + "test:e2e": "E2E_TEST=true playwright test" }, "keywords": [], "author": { @@ -32,20 +34,24 @@ "@popperjs/core": "^2.11.8", "@radix-ui/react-alert-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", + "@reduxjs/toolkit": "^2.3.0", "@sentry/electron": "^5.2.0", "@sentry/react": "^8.17.0", "@tailwindcss/forms": "^0.5.4", - "@tanstack/react-query": "4", + "@tanstack/react-query": "^5.59.19", "@trpc/client": "latest", - "@trpc/react-query": "latest", "@trpc/server": "latest", + "@types/body-parser": "^1.19.5", + "@types/express": "^5.0.0", "@types/progress-stream": "^2.0.2", "@types/qs": "^6.9.9", "@types/react": "^18.2.33", "@types/react-dom": "^18.2.14", + "@types/react-redux": "^7.1.34", "@types/uuid": "^9.0.2", "@types/which": "^3.0.0", "badger-server": "workspace:*", + "body-parser": "^1.20.3", "bufferutil": "^4.0.7", "classnames": "^2.3.2", "client-only": "^0.0.1", @@ -54,8 +60,11 @@ "electron-settings": "^4.0.2", "electron-squirrel-startup": "^1.0.0", "electron-trpc": "^0.6.0", + "express": "^4.21.1", "fast-xml-parser": "^4.2.7", "got": "^13.0.0", + "immer": "^10.1.1", + "is-electron": "^2.2.2", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "obs-websocket-js": "^5.0.3", @@ -65,21 +74,25 @@ "react-hook-form": "^7.45.2", "react-icons": "^4.10.1", "react-popper": "^2.3.0", + "react-redux": "^9.1.2", + "redux": "^5.0.1", "rxjs": "^7.8.1", "superjson": "^1.13.1", "ts-expect": "^1.3.0", - "typescript": "^5.1.6", + "typescript": "^5.6.3", "utf-8-validate": "^6.0.3", "uuid": "^9.0.0", "wget-improved": "^3.4.0", "which": "^4.0.0", - "ws": "^8.13.0", + "ws": "^8.18.0", "zod": "3.23.8" }, "devDependencies": { "@aws-sdk/client-s3": "^3.474.0", "@badger/testing": "workspace:*", "@playwright/test": "^1.43.1", + "@redux-devtools/cli": "^4.0.0", + "@redux-devtools/remote": "^0.9.3", "@rollup/plugin-alias": "^5.1.0", "@sentry/vite-plugin": "^2.17.0", "@tsconfig/vite-react": "^3.0.0", @@ -88,9 +101,11 @@ "@typescript-eslint/parser": "^7.0.0", "@vitest/coverage-v8": "^2.0.0", "autoprefixer": "^10.4.14", + "concurrently": "^9.0.1", "electron": "31.7.1", "electron-builder": "^24.13.3", "electron-builder-squirrel-windows": "^25.0.0-alpha.7", + "electron-devtools-installer": "^3.2.0", "electron-vite": "^2.2.0", "electron-wix-msi": "^5.0.0", "eslint": "^8", @@ -105,9 +120,9 @@ "rollup-plugin-visualizer": "^5.12.0", "strong-mock": "^9.0.0", "tailwindcss": "^3.3.3", - "tsx": "^4.16.2", "undici": "^6.13.0", "vite": "^5.2.11", + "vite-node": "^2.1.4", "vite-plugin-commonjs": "^0.10.1", "vitest": "^2.0.0", "webpack": "^5.91.0" diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index cd50ae2fb..58058ee21 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "@playwright/test"; +import { defineConfig, devices } from "@playwright/test"; /** * Read environment variables from file. @@ -10,7 +10,6 @@ import { defineConfig } from "@playwright/test"; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: "./e2e", /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -34,22 +33,48 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ { - name: "complete", - use: {}, - testDir: "./e2e/complete", + name: "standalone", + use: { + ...devices["Desktop Chrome"], + }, + testDir: "./e2e/standalone", }, { - name: "standalone", + name: "complete", use: {}, - testDir: "./e2e/standalone", + testDir: "./e2e/complete", + fullyParallel: false, }, ], /* Run your local dev server before starting the tests */ - webServer: { - command: "yarn microserver", - cwd: "../server", - url: "http://127.0.0.1:8594", - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: "npx -y @redux-devtools/cli --port 5175", + url: "http://localhost:5175", + reuseExistingServer: !process.env.CI, + }, + { + command: "yarn devServer", + url: "http://localhost:5174/getState", + reuseExistingServer: !process.env.CI, + env: { + ...process.env, + ENABLE_REDUX_DEVTOOLS: "true", + }, + stdout: "pipe", + stderr: "pipe", + }, + { + command: "yarn devRenderer", + url: "http://localhost:5173", + reuseExistingServer: !process.env.CI, + }, + { + command: "yarn microserver", + cwd: "../server", + url: "http://localhost:8594", + reuseExistingServer: !process.env.CI, + }, + ], }); diff --git a/desktop/src/common/preload.ts b/desktop/src/common/preload.ts index 0fad71c54..56856f1a8 100644 --- a/desktop/src/common/preload.ts +++ b/desktop/src/common/preload.ts @@ -1,33 +1,19 @@ import "@sentry/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; -import { exposeElectronTRPC } from "electron-trpc/main"; -import { Events } from "./ipcEvents"; -import invariant from "./invariant"; +import { DeepPartial } from "react-hook-form"; +import type { AppState } from "../main/store"; -process.once("loaded", async () => { - exposeElectronTRPC(); - contextBridge.exposeInMainWorld("IPCEventBus", { - on: (evt: keyof Events, callback: (...args: unknown[]) => void) => { - // This invariant is necessary to avoid a malicious renderer process registering arbitrary event handlers. - invariant( - evt in Events, - "Tried to register event handler for non-exposed event type", - ); - ipcRenderer.on(evt, callback); - }, - once: (evt: keyof Events, callback: (...args: unknown[]) => void) => { - invariant( - evt in Events, - "Tried to register event handler for non-exposed event type", - ); - ipcRenderer.once(evt, callback); - }, - off: (evt: keyof Events, callback: (...args: unknown[]) => void) => { - invariant( - evt in Events, - "Tried to register event handler for non-exposed event type", - ); - ipcRenderer.off(evt, callback); +process.once("loaded", () => { + contextBridge.exposeInMainWorld("MainStoreAPI", { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _dispatch: (...params: any[]) => ipcRenderer.invoke("dispatch", ...params), + onStateChange: ( + callback: (actionType: string, newState: DeepPartial) => void, + ) => { + ipcRenderer.on("stateChange", (_event, actionType, newState) => { + callback("@@main/" + actionType, newState); + }); }, + getState: () => ipcRenderer.invoke("getState"), }); }); diff --git a/desktop/src/main/base/integrations.ts b/desktop/src/main/base/integrations.ts index 6b93c517a..a5b30bb6e 100644 --- a/desktop/src/main/base/integrations.ts +++ b/desktop/src/main/base/integrations.ts @@ -1,18 +1,34 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { Integration } from "../../common/types"; -export let supportedIntegrations: Integration[]; -// This is fairly rudimentary -if ( - process.env.E2E_TEST === "true" && - process.env.__TEST_SUPPORTED_INTEGRATIONS -) { - supportedIntegrations = JSON.parse(process.env.__TEST_SUPPORTED_INTEGRATIONS); -} else if (process.platform === "win32") { - supportedIntegrations = ["vmix", "obs", "ontime"]; -} else { - supportedIntegrations = ["obs", "ontime"]; +function getSupportedIntegrations(): Integration[] { + // This is fairly rudimentary + if ( + process.env.E2E_TEST === "true" && + process.env.__TEST_SUPPORTED_INTEGRATIONS + ) { + return JSON.parse(process.env.__TEST_SUPPORTED_INTEGRATIONS); + } else if (process.platform === "win32") { + return ["vmix", "obs", "ontime"]; + } else { + return ["obs", "ontime"]; + } } -export function DEV_overrideSupportedIntegrations(integrations: Integration[]) { - supportedIntegrations = integrations; -} +const integrationsSlice = createSlice({ + name: "integrations", + initialState: { + supported: getSupportedIntegrations(), + }, + reducers: { + overrideSupportedIntegrations: ( + state, + action: PayloadAction, + ) => { + state.supported = action.payload; + }, + }, +}); + +export const integrationsReducer = integrationsSlice.reducer; +export const { overrideSupportedIntegrations } = integrationsSlice.actions; diff --git a/desktop/src/main/base/logging.ts b/desktop/src/main/base/logging.ts index a8584d93e..0114705b0 100644 --- a/desktop/src/main/base/logging.ts +++ b/desktop/src/main/base/logging.ts @@ -1,8 +1,21 @@ import logging, { LogLevelNames } from "loglevel"; import prefix from "loglevel-plugin-prefix"; -import { app } from "electron"; import path from "path"; import fs from "fs"; +import isElectron from "is-electron"; +import { listenOnStore } from "../storeListener"; + +let app: Electron.App; +if (isElectron()) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + app = require("electron").app; +} else { + app = { + getPath: () => process.cwd(), + on: () => {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} const logsPath = process.env.BADGER_LOGS_PATH ?? path.join(app.getPath("userData"), "logs"); @@ -27,7 +40,7 @@ logging.methodFactory = function (level) { }; }; -export let logLevel = (process.env.LOG_LEVEL as LogLevelNames) ?? "debug"; +export let logLevel = (process.env.BADGER_LOG_LEVEL as LogLevelNames) ?? "info"; logging.setLevel(logLevel); prefix.reg(logging); prefix.apply(logging, { @@ -48,13 +61,27 @@ export function getLogger(name: string) { } export function setLogLevel(level: LogLevelNames) { - logging[level](`Changing log level to ${level}`); + if (process.env.BADGER_LOG_LEVEL) { + logging.info( + `Ignoring request to change log level to ${level} because it is set using BADGER_LOG_LEVEL.`, + ); + return; + } logLevel = level; for (const logger of loggers) { logging.getLogger(logger).setLevel(level); } + logging[level](`Changed log level to ${level}`); } +listenOnStore({ + predicate: (_, oldState, newState) => + oldState.settings.logging.level !== newState.settings.logging.level, + effect: (_, api) => { + setLogLevel(api.getState().settings.logging.level); + }, +}); + export default { getLogger, }; diff --git a/desktop/src/main/base/reduxHelpers.ts b/desktop/src/main/base/reduxHelpers.ts new file mode 100644 index 000000000..c4830ee2f --- /dev/null +++ b/desktop/src/main/base/reduxHelpers.ts @@ -0,0 +1,6 @@ +import { buildCreateSlice, asyncThunkCreator } from "@reduxjs/toolkit"; + +// https://redux-toolkit.js.org/api/createSlice#createasyncthunk +export const createAppSlice = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, +}); diff --git a/desktop/src/main/base/selectedShow.ts b/desktop/src/main/base/selectedShow.ts index d780f6c74..343c99702 100644 --- a/desktop/src/main/base/selectedShow.ts +++ b/desktop/src/main/base/selectedShow.ts @@ -1,39 +1,120 @@ import { CompleteShowType } from "../../common/types"; -import { serverApiClient } from "./serverApiClient"; -import { BehaviorSubject } from "rxjs"; -import invariant from "../../common/invariant"; +import { serverAPI } from "./serverApiClient"; +import { + createAsyncThunk, + createSlice, + isAnyOf, + PayloadAction, + TaskAbortError, +} from "@reduxjs/toolkit"; +import { listenOnStore } from "../storeListener"; +import { getLogger } from "./logging"; -export const selectedShow = new BehaviorSubject(null); +const logger = getLogger("selectedShow"); -export async function setSelectedShow(show: CompleteShowType) { - selectedShow.next(show); - if (timer === null) { - checkForChangesLoop(); - } -} +const selectedShowState = createSlice({ + name: "selectedShow", + initialState: { + show: null as CompleteShowType | null, + isLoading: false, + }, + reducers: { + _updateShowData: (state, action: PayloadAction) => { + state.show = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(changeSelectedShow.pending, (state) => { + state.isLoading = true; + }); + builder.addCase(changeSelectedShow.fulfilled, (state, action) => { + state.show = action.payload; + state.isLoading = false; + }); + // TODO handle error? (Probably want some kind of global handler) + }, +}); -async function doCheckForChanges() { - const v = selectedShow.value; - if (v === null) { - return false; - } - invariant(serverApiClient !== null, "serverApiClient is null"); - const newV = await serverApiClient.shows.getVersion.query({ - id: v.id, - }); - return v.version !== newV.version; +export const selectedShowReducer = selectedShowState.reducer; + +export const changeSelectedShow = createAsyncThunk( + "selectedShow/changeSelectedShow", + async (showID: number) => { + return await serverAPI().shows.get.query({ id: showID }); + }, +); + +/** + * Slices can use this to listen for changes to the selected show data. + */ +export const showDataChangeMatcher = isAnyOf( + changeSelectedShow.fulfilled.match, + selectedShowState.actions._updateShowData.match, +); + +listenOnStore({ + actionCreator: changeSelectedShow.fulfilled, + effect: async (initialShowState, api) => { + api.cancelActiveListeners(); + api.fork(async (forkAPI) => { + logger.debug("Starting show data update loop"); + for (;;) { + try { + await forkAPI.delay(10_000); // TODO configurable + const { show: current } = api.getState().selectedShow; + if (current === null || current.id !== initialShowState.payload.id) { + return; + } + const serverVersion = await serverAPI().shows.getVersion.query({ + id: current.id, + }); + if (serverVersion.version === current.version) { + continue; + } + const newData = await serverAPI().shows.get.query({ + id: current.id, + }); + api.dispatch(selectedShowState.actions._updateShowData(newData)); + } catch (e) { + if (e instanceof TaskAbortError) { + throw e; + } + logger.error("Error updating show data", e); // TODO surface to user + } + } + }); + // Cancel as soon as a new show is selected + await api.condition(changeSelectedShow.pending.match); + api.cancel(); + logger.debug("Cancelled show data update loop"); + }, +}); + +// This hackery allows all other slice reducers to access selectedShow without +// needing to maintain a copy in their slice. See the comment in store.ts +// for more detail (including why it's legal). + +// This sigil allows us to enforce that getSelectedShow is only called within a reducer. +// The value of state is this sigil whenever we're not in a reducer. +const sigil = Symbol("SelectedShow_notInReducer"); +let state: CompleteShowType | null | typeof sigil = sigil; +export function _enterReducer(value: CompleteShowType | null) { + state = value; +} +export function _exitReducer() { + state = sigil; } -let timer: NodeJS.Timeout | null = null; -async function checkForChangesLoop() { - if (await doCheckForChanges()) { - invariant(serverApiClient !== null, "serverApiClient is null"); - if (selectedShow) { - const newData = await serverApiClient.shows.get.query({ - id: selectedShow.value!.id, - }); - selectedShow.next(newData); - } +/** + * Get the currently selected show. + * **This is only legal to call within a Redux reducer**, during the top-level store update cycle. Any other + * usage will throw an error. + */ +export function getSelectedShow() { + if (state === sigil) { + throw new Error( + "getSelectedShow called outside of a reducer. It is only valid inside a Redux reducer. For all other uses, access the state directly.", + ); } - timer = setTimeout(checkForChangesLoop, 10_000); + return state; } diff --git a/desktop/src/main/base/serverApiClient.ts b/desktop/src/main/base/serverApiClient.ts index f86077824..4f2b530f7 100644 --- a/desktop/src/main/base/serverApiClient.ts +++ b/desktop/src/main/base/serverApiClient.ts @@ -7,15 +7,14 @@ import { } from "@trpc/client"; import type { AppRouter } from "badger-server/app/api/_router"; import superjson from "superjson"; -import { getServerSettings, saveServerSettings } from "./settings"; import logging from "./logging"; import invariant from "../../common/invariant"; const logger = logging.getLogger("serverApiClient"); -export let serverApiClient: CreateTRPCProxyClient | null = null; +let serverApiClient: CreateTRPCProxyClient | null = null; -async function newAPIClient(endpoint: string, password: string) { +export async function newAPIClient(endpoint: string, password: string) { const client = createTRPCProxyClient({ links: [ loggerLink({ @@ -39,7 +38,7 @@ async function newAPIClient(endpoint: string, password: string) { }), // We disable batching in E2E tests to make mocking easier (process.env.E2E_TEST === "true" ? httpLink : httpBatchLink)({ - url: endpoint, + url: endpoint + "/api/trpc", headers: () => ({ authorization: `Bearer ${password}`, }), @@ -47,38 +46,29 @@ async function newAPIClient(endpoint: string, password: string) { ], transformer: superjson, }); - const pingResponse = await client.ping.query(); - if (pingResponse.version !== global.__APP_VERSION__) { - logger.warn( - `Warning: version skew detected: server is running ${pingResponse.version}, but client is running ${global.__APP_VERSION__}`, - ); - } return client; } -export async function createAPIClient(endpoint: string, password: string) { - serverApiClient = await newAPIClient(endpoint, password); - await saveServerSettings({ endpoint, password }); +export function _setServerApiClient(client: CreateTRPCProxyClient) { + serverApiClient = client; } -export async function tryCreateAPIClient() { - let settings; - try { - settings = await getServerSettings(); - } catch (e) { - logger.warn("Failed to load server settings", e, "Continuing anyway."); - return; - } - if (settings !== null) { - try { - serverApiClient = await newAPIClient( - settings.endpoint, - settings.password, - ); - } catch (e) { - logger.warn("Failed to connect to server (will continue)", e); - } +/** + * Check if the server and client are running the same version of the app. + * Takes in a client, rather than creating one, so that it can also be used + * as a check if the connection works; + */ +export async function checkForVersionSkew( + client: CreateTRPCProxyClient, +) { + const pingResponse = await client.ping.query(); + if (pingResponse.version !== global.__APP_VERSION__) { + logger.warn( + `Warning: version skew detected: server is running ${pingResponse.version}, but client is running ${global.__APP_VERSION__}`, + ); + return true; } + return false; } export function serverAPI() { diff --git a/desktop/src/main/base/serverConnectionState.ts b/desktop/src/main/base/serverConnectionState.ts new file mode 100644 index 000000000..7c87709fb --- /dev/null +++ b/desktop/src/main/base/serverConnectionState.ts @@ -0,0 +1,87 @@ +import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit"; +import { + _setServerApiClient, + checkForVersionSkew, + newAPIClient, +} from "./serverApiClient"; +import { AppState } from "../store"; + +const serverConnectionSlice = createSlice({ + name: "serverConnection", + initialState: { + state: "disconnected" as "disconnected" | "connecting" | "connected", + error: null as string | null, + server: "", + versionSkew: false, + }, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(tryConnectToServer.pending, (state) => { + state.state = "connecting"; + }); + + builder.addCase(tryConnectToServer.rejected, (state) => { + state.state = "disconnected"; + // no error here + }); + + builder.addCase(connectToServer.pending, (state) => { + state.state = "connecting"; + }); + + builder.addCase(connectToServer.rejected, (state, action) => { + state.state = "disconnected"; + state.error = action.error.message ?? "Unknown error"; + }); + + builder.addMatcher(serverConnected, (state, action) => { + if (action.payload) { + state.state = "connected"; + state.error = null; + state.versionSkew = action.payload.versionSkew; + } else { + state.state = "disconnected"; + } + }); + }, +}); + +export const serverConnectionReducer = serverConnectionSlice.reducer; + +async function _tryConnect(endpoint: string, password: string) { + const client = await newAPIClient(endpoint, password); + const versionSkew = await checkForVersionSkew(client); + _setServerApiClient(client); + return { + versionSkew, + }; +} + +export const connectToServer = createAsyncThunk( + "serverConnection/connect", + async (data: { host: string; password: string; silenceErrors?: boolean }) => { + return await _tryConnect(data.host, data.password); + }, +); + +export const tryConnectToServer = createAsyncThunk( + "serverConnection/tryConnect", + async (_, { getState }) => { + const settings = (getState() as AppState).settings; + if ( + !settings.server.endpoint?.length || + settings.server.password.length === 0 + ) { + return false; + } + return await _tryConnect( + settings.server.endpoint, + settings.server.password, + ); + }, +); + +export const serverConnected = isAnyOf( + connectToServer.fulfilled, + tryConnectToServer.fulfilled, +); diff --git a/desktop/src/main/base/serverDataState.ts b/desktop/src/main/base/serverDataState.ts new file mode 100644 index 000000000..b22ebc405 --- /dev/null +++ b/desktop/src/main/base/serverDataState.ts @@ -0,0 +1,43 @@ +import { serverAPI } from "./serverApiClient"; +import { listenOnStore } from "../storeListener"; +import { serverConnected } from "./serverConnectionState"; +import { createAppSlice } from "./reduxHelpers"; + +export const serverDataSlice = createAppSlice({ + name: "serverData", + initialState: { + upcomingShows: [] as { id: number; name: string }[], + upcomingShowsLoading: false, + upcomingShowsError: null as string | null, + }, + reducers: (builder) => ({ + updateUpcomingShows: builder.asyncThunk( + async () => { + const result = await serverAPI().shows.listUpcoming.query(); + return result.map((show) => ({ id: show.id, name: show.name })); + }, + { + pending: (state) => { + state.upcomingShowsLoading = true; + state.upcomingShowsError = null; + }, + fulfilled: (state, action) => { + state.upcomingShowsLoading = false; + state.upcomingShows = action.payload; + }, + rejected: (state, action) => { + state.upcomingShowsLoading = false; + state.upcomingShowsError = action.error.message ?? "Unknown error"; + }, + }, + ), + }), +}); + +// Eagerly fetch the show list when we connect +listenOnStore({ + matcher: serverConnected, + effect: (_, api) => { + api.dispatch(serverDataSlice.actions.updateUpcomingShows()); + }, +}); diff --git a/desktop/src/main/base/settings.ts b/desktop/src/main/base/settings.ts index 4687ad957..164813592 100644 --- a/desktop/src/main/base/settings.ts +++ b/desktop/src/main/base/settings.ts @@ -1,178 +1,149 @@ -import electronSettings from "electron-settings"; -import { safeStorage } from "./safeStorage"; -import { z } from "zod"; - -/* - * In E2E tests we don't want settings to persist between tests, so we use - * an in-memory store that can be reset by the test harness. - */ - -interface SettingsStore { - get(key: string): Promise; - set(key: string, value: unknown): Promise; - unset(key: string): Promise; -} - -const testSettingsBacking = new Map(); -const testSettingsStore: SettingsStore = { - async get(key: string) { - if (process.env[`__TEST_SETTINGS_${key.toUpperCase()}`]) { - // TODO[BDGR-175]: validate that this matches the requisite schema - // Will require slightly refactoring this file to allow getting the schema - // by name, though as a bonus this should let us avoid duplicating the load/save code - return JSON.parse( - process.env[`__TEST_SETTINGS_${key.toUpperCase()}`] as string, - ); - } - return testSettingsBacking.get(key); +import { + createAction, + createAsyncThunk, + createReducer, +} from "@reduxjs/toolkit"; +import { AnyZodObject, z, ZodType } from "zod"; +import { set, throttle, isEqual, cloneDeep, defaultsDeep } from "lodash"; +import { getSettingsStore } from "./settingsStorage"; +import { listenOnStore } from "../storeListener"; +import { connectToServer } from "./serverConnectionState"; +import { getLogger } from "./logging"; +import { connectToOBS } from "../obs/state"; +import { original } from "immer"; +import type { LogLevelNames } from "loglevel"; + +const logger = getLogger("settings"); + +export const AppSettingsSchema = z.object({ + server: z.object({ + endpoint: z.string().url().or(z.null()), + password: z.string(), + }), + obs: z.object({ + host: z.string(), + port: z.number(), + password: z.string(), + }), + media: z.object({ + mediaPath: z.string(), + downloader: z.enum(["Auto", "Node", "Curl"]), + }), + devtools: z.object({ + enabled: z.boolean(), + }), + ontime: z.object({ + host: z.string().url().or(z.null()), + }), + logging: z.object({ + level: z.enum(["trace", "debug", "info", "warn", "error"]), + }), +}); +export type AppSettings = z.infer; +const defaultSettings: AppSettings = { + devtools: { + enabled: false, }, - async set(key: string, value: unknown) { - testSettingsBacking.set(key, value); + media: { + mediaPath: "", + downloader: "Auto", }, - async unset(key: string) { - testSettingsBacking.delete(key); + obs: { + host: "", + port: 4455, + password: "", + }, + ontime: { + host: null, + }, + server: { + endpoint: null, + password: "", + }, + logging: { + level: (process.env.BADGER_LOG_LEVEL as LogLevelNames) ?? "info", }, }; - -const settings: SettingsStore = - process.env.E2E_TEST === "true" ? testSettingsStore : electronSettings; - -export async function migrateSettings() { - await settings.unset("localMedia"); -} - -/** - * Since settings are stored as JSON files on disk, we pass them through zod as a sanity check. - */ -const ServerSettingsSchema = z.object({ - endpoint: z.string().url(), - password: z.string(), -}); - -export async function getServerSettings(): Promise | null> { - const settingsDataRaw = await settings.get("server"); - if (settingsDataRaw === undefined) { - return null; - } - const settingsData = ServerSettingsSchema.parse(settingsDataRaw); - settingsData.password = safeStorage.decryptString( - Buffer.from(settingsData.password, "base64"), - ); - return settingsData; -} - -export async function saveServerSettings( - valRaw: z.infer, -): Promise { - const val = { ...valRaw }; - val.password = safeStorage.encryptString(val.password).toString("base64"); - await settings.set("server", val); -} - -const OBSSettingsSchema = z.object({ - host: z.string(), - port: z.number(), - password: z.string(), -}); - -export async function getOBSSettings(): Promise | null> { - const settingsDataRaw = await settings.get("obs"); - if (settingsDataRaw === undefined) { - return null; - } - const settingsData = OBSSettingsSchema.parse(settingsDataRaw); - settingsData.password = safeStorage.decryptString( - Buffer.from(settingsData.password, "base64"), - ); - return settingsData; -} - -export async function saveOBSSettings( - valIn: z.infer, -): Promise { - const val = { ...valIn }; - val.password = safeStorage.encryptString(val.password).toString("base64"); - await settings.set("obs", val); -} - -const MediaSettingsSchema = z.object({ - mediaPath: z.string(), -}); - -export async function getMediaSettings(): Promise | null> { - const settingsData = await settings.get("media"); - if (settingsData === undefined) { - return null; - } - return MediaSettingsSchema.parse(settingsData); -} - -export async function saveMediaSettings( - val: z.infer, -): Promise { - await settings.set("media", val); -} - -export const devToolsConfigSchema = z.object({ - enabled: z.boolean(), +export const SettingsStateSchema = AppSettingsSchema.extend({ + _loaded: z.boolean(), }); -export type DevToolsConfigType = z.infer; - -export async function getDevToolsConfig(): Promise< - z.infer -> { - const settingsData = await settings.get("devTools"); - if (settingsData === undefined) { - return { enabled: false }; - } - return devToolsConfigSchema.parse(settingsData); -} - -export async function saveDevToolsConfig( - val: z.infer, -): Promise { - await settings.set("devTools", val); -} +type SettingsState = z.infer; + +export const setSetting = createAction( + "settings/setSetting", + (group: keyof AppSettings, field: string, value: unknown) => { + const groupSchema: AnyZodObject = + AppSettingsSchema.shape[group as keyof AppSettings]; + const fieldSchema: ZodType = groupSchema.shape[field]; + const val = fieldSchema.parse(value); + return { payload: { key: `${group}.${field}`, val } }; + }, +); + +export const initialiseSettings = createAsyncThunk( + "settings/initialise", + async () => { + if (process.env.E2E_TEST === "true") { + logger.info("Running in E2E test mode, not loading settings from disk"); + return; + } + const store = await getSettingsStore(); + return await store.loadSettings(); + }, +); -export const ontimeSettingsSchema = z.object({ - host: z.string().url(), +export const settingsReducer = createReducer({} as SettingsState, (builder) => { + builder.addCase(initialiseSettings.fulfilled, (state, action) => { + if (!action.payload) { + state._loaded = true; + return; + } + return { + ...action.payload, + _loaded: true, + }; + }); + builder.addCase(setSetting, (state, action) => { + set(state, action.payload.key, action.payload.val); + SettingsStateSchema.parse(state); // validate + }); + + // Save server connection details when we connect + builder.addCase(connectToServer.fulfilled, (state, action) => { + state.server.endpoint = action.meta.arg.host; + state.server.password = action.meta.arg.password; + }); + // dto for OBS + builder.addCase(connectToOBS.fulfilled, (state, action) => { + state.obs.host = action.meta.arg.host; + state.obs.port = action.meta.arg.port || 4455; + state.obs.password = action.meta.arg.password; + }); + + // This ensures that the state always has at least the defaults + builder.addDefaultCase((state) => { + const rv = cloneDeep(original(state)) ?? {}; + defaultsDeep(rv, defaultSettings); + return { _loaded: true, ...rv } as SettingsState; + }); }); -export type OntimeSettings = z.infer; -export async function getOntimeSettings(): Promise { - const settingsData = await settings.get("ontime"); - if (settingsData === undefined) { - return null; +const doSaveSettings = throttle(async function (settings: AppSettings) { + if (process.env.E2E_TEST === "true") { + logger.info("Running in E2E test mode, not saving settings to disk"); + return; } - return ontimeSettingsSchema.parse(settingsData); -} - -export async function saveOntimeSettings(val: OntimeSettings): Promise { - await settings.set("ontime", val); -} - -export const downloadsSettingsSchema = z.object({ - downloader: z.enum(["Auto", "Node", "Curl"]), + const store = await getSettingsStore(); + await store.saveSettings(settings); +}, 500); + +listenOnStore({ + predicate: (action, newState, oldState) => + !isEqual(newState.settings, oldState.settings) && + !initialiseSettings.fulfilled.match(action), + effect: async (_, api) => { + const newSettings = api.getState().settings; + // TODO: feedback? + await doSaveSettings(newSettings); + }, }); - -export type DownloadsSettings = z.infer; - -export async function getDownloadsSettings(): Promise { - const settingsData = await settings.get("downloads"); - if (settingsData === undefined) { - return { downloader: "Auto" }; - } - return downloadsSettingsSchema.parse(settingsData); -} - -export async function saveDownloadsSettings( - val: DownloadsSettings, -): Promise { - await settings.set("downloads", val); -} diff --git a/desktop/src/main/base/settingsStorage.dev.ts b/desktop/src/main/base/settingsStorage.dev.ts new file mode 100644 index 000000000..c7f3913ea --- /dev/null +++ b/desktop/src/main/base/settingsStorage.dev.ts @@ -0,0 +1,53 @@ +// Copy of settingsStorage.ts but doesn't use Electron. +import { AppSettings, SettingsStateSchema } from "./settings"; +import { once, cloneDeep } from "lodash"; +import * as fsp from "fs/promises"; +import { getLogger } from "./logging"; +import { inspect } from "util"; +import * as nodePath from "path"; +import isElectron from "is-electron"; +import invariant from "../../common/invariant"; + +invariant(!isElectron(), "This file should not be used in Electron"); + +const logger = getLogger("settingsStorage.dev"); + +const dir = nodePath.join(process.cwd(), ".dev"); +const path = once(() => nodePath.join(dir, "settings.json")); + +export async function saveSettings(val: AppSettings) { + if (process.env.E2E_TEST === "true") { + logger.info("Skipping saving settings in E2E test"); + return; + } + const nv = cloneDeep(val); + const data = JSON.stringify(nv, null, 2); + await fsp.mkdir(dir, { recursive: true }); + await fsp.writeFile(path(), data, { encoding: "utf-8", flag: "w" }); + logger.info("Saved settings"); +} + +export async function loadSettings(): Promise { + if (process.env.E2E_TEST === "true") { + logger.info("Skipping loading settings in E2E test"); + return SettingsStateSchema.parse(undefined); + } + let data; + try { + data = await fsp.readFile(path(), { encoding: "utf-8" }); + } catch (e) { + if ( + e instanceof Error && + (e as unknown as { code: string }).code === "ENOENT" + ) { + return SettingsStateSchema.parse(undefined); + } + throw e; + } + const settings = SettingsStateSchema.safeParse(JSON.parse(data)); + if (!settings.success) { + logger.error("Failed to parse settings: " + inspect(settings.error)); + return SettingsStateSchema.parse(undefined); + } + return settings.data; +} diff --git a/desktop/src/main/base/settingsStorage.electron.ts b/desktop/src/main/base/settingsStorage.electron.ts new file mode 100644 index 000000000..b0c1a99dd --- /dev/null +++ b/desktop/src/main/base/settingsStorage.electron.ts @@ -0,0 +1,73 @@ +import { app } from "electron/main"; +import { AppSettings, SettingsStateSchema } from "./settings"; +import { once, cloneDeep } from "lodash"; +import * as fsp from "fs/promises"; +import { safeStorage } from "electron"; +import { getLogger } from "./logging"; +import { inspect } from "util"; + +const logger = getLogger("settingsStorage"); + +const path = once(() => app.getPath("userData") + "/settings.json"); + +type DeepTrue = { + [K in keyof T]?: T[K] extends object ? DeepTrue : true; +}; +const encryptedFields: DeepTrue = { + server: { + password: true, + }, + obs: { + password: true, + }, +}; + +export async function saveSettings(val: AppSettings) { + await app.whenReady(); + const nv = cloneDeep(val); + for (const [group, fields] of Object.entries(encryptedFields)) { + for (const field of Object.keys(fields)) { + // @ts-expect-error typing is complicated + const encrypted = safeStorage.encryptString(nv[group][field]); + // @ts-expect-error typing is complicated + nv[group][field] = encrypted.toString("base64"); + } + } + const data = JSON.stringify(nv, null, 2); + await fsp.writeFile(path(), data, { encoding: "utf-8", flag: "w" }); + logger.info("Saved settings"); +} + +export async function loadSettings(): Promise { + await app.whenReady(); + let data; + try { + data = await fsp.readFile(path(), { encoding: "utf-8" }); + } catch (e) { + if ( + e instanceof Error && + (e as unknown as { code: string }).code === "ENOENT" + ) { + return SettingsStateSchema.parse(undefined); + } + throw e; + } + const settings = SettingsStateSchema.safeParse(JSON.parse(data)); + if (!settings.success) { + logger.error("Failed to parse settings: " + inspect(settings.error)); + return SettingsStateSchema.parse(undefined); + } + // Decrypt encrypted fields + for (const [group, fields] of Object.entries(encryptedFields)) { + for (const field of Object.keys(fields)) { + // @ts-expect-error typing is complicated + const encrypted = settings.data[group][field]; + const decrypted = safeStorage.decryptString( + Buffer.from(encrypted, "base64"), + ); + // @ts-expect-error typing is complicated + settings.data[group][field] = decrypted; + } + } + return settings.data; +} diff --git a/desktop/src/main/base/settingsStorage.ts b/desktop/src/main/base/settingsStorage.ts new file mode 100644 index 000000000..64f76d2ac --- /dev/null +++ b/desktop/src/main/base/settingsStorage.ts @@ -0,0 +1,13 @@ +import { AppSettings } from "./settings"; +import isElectron from "is-electron"; + +export interface SettingsStore { + saveSettings: (val: AppSettings) => Promise; + loadSettings: () => Promise; +} + +export async function getSettingsStore(): Promise { + return !isElectron() + ? await import("./settingsStorage.dev") + : await import("./settingsStorage.electron"); +} diff --git a/desktop/src/main/devServer.ts b/desktop/src/main/devServer.ts new file mode 100644 index 000000000..cb37c25e2 --- /dev/null +++ b/desktop/src/main/devServer.ts @@ -0,0 +1,101 @@ +/* eslint-disable no-console */ +/** + * Only used in development. Start up the core logic without any Electron bits, + * and exposes an API that the "renderer" (running in a browser) can use to + * interact with the core logic. + */ + +import { createServer } from "node:http"; +import { WebSocketServer } from "ws"; +import { + AppStore, + exposedActionCreators, + ExposedActionCreators, + store, +} from "./store"; +import { listenOnStore } from "./storeListener"; +import { doPreflight } from "./preflight"; +import express from "express"; +import { json as bodyParserJSON } from "body-parser"; +import { Action } from "redux"; +import { getLogger } from "./base/logging"; + +const logger = getLogger("devServer"); + +const DEV_SERVER_PORT = 5174; + +export function createReduxDevServer( + store: AppStore, + exposedActionCreators: ExposedActionCreators, +) { + const app = express(); + app.use(bodyParserJSON()); + app.get("/getState", (_req, res) => { + res.json(store.getState()); + }); + app.post("/dispatch/:actionType", (req, res) => { + const type = req.params.actionType as keyof ExposedActionCreators; + if (!(type in exposedActionCreators)) { + res.status(404).end(`Action ${type} not found`); + return; + } + const body = req.body; + if (!Array.isArray(body)) { + res.status(400).end("Expected JSON array body"); + return; + } + const action = exposedActionCreators[type]( + // @ts-expect-error Some action creators don't take any arguments + ...body, + ); + const result = store.dispatch(action as Action); + if (isThenable(result)) { + result.then( + (result: unknown) => { + res.json(result); + }, + (error: unknown) => { + res.status(500).end(String(error)); + }, + ); + } else { + res.json(result); + } + }); + app.post("/reset", async (req, res) => { + const preloadedState = req.body; + logger.info("received reset request"); + store.dispatch({ type: "@@RESET" }); + if (typeof preloadedState === "object") { + store.dispatch({ type: "@@PRELOAD", payload: preloadedState }); + } + store.dispatch(doPreflight()); + res.status(200).json({ ok: true, newState: store.getState() }); + }); + const server = createServer(app); + const wss = new WebSocketServer({ server }); + wss.on("connection", (ws) => { + const unsub = listenOnStore({ + predicate: () => true, + effect: (action, api) => { + const state = api.getState(); + ws.send(JSON.stringify({ type: action.type, state })); + }, + }); + ws.on("close", unsub); + }); + console.log( + `Redux Dev Server listening on http://localhost:${DEV_SERVER_PORT}`, + ); + server.listen(DEV_SERVER_PORT); + if (process.env.E2E_TEST !== "true") { + console.log("Doing preflight"); + store.dispatch(doPreflight()); + } +} + +createReduxDevServer(store, exposedActionCreators); + +function isThenable(obj: unknown): obj is Promise { + return obj != null && typeof (obj as Promise).then === "function"; +} diff --git a/desktop/src/main/extras.d.ts b/desktop/src/main/extras.d.ts index 89a5a5d14..0d054cd43 100644 --- a/desktop/src/main/extras.d.ts +++ b/desktop/src/main/extras.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-var */ declare module "electron-squirrel-startup" { let isSquirrel: boolean; exports = isSquirrel; diff --git a/desktop/src/main/globalError.ts b/desktop/src/main/globalError.ts new file mode 100644 index 000000000..0df162f71 --- /dev/null +++ b/desktop/src/main/globalError.ts @@ -0,0 +1,58 @@ +import { AsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import { createAppSlice } from "./base/reduxHelpers"; +import { callArbitrary, updateContinuityScenes } from "./obs/state"; +import { updateLoadState } from "./vmix/state"; + +/** + * Redux slice for managing global error state. + * + * @remarks + * This slice handles the storage and management of global application errors, + * including async operation failures and their dismissal. This is shown in the + * UI as a banner at the top of the screen. This is intended for use where having + * error UI in the component that caused the error is not possible or practical, + * for instance for background tasks. + * + * To trigger a banner from an error, add a handler in the `extraReducers` section + * for the action creator that can fail. + */ +const globalErrorSlice = createAppSlice({ + name: "globalError", + initialState: { + errors: [] as { id: number; message: string; reason: string }[], + lastID: 0, + }, + reducers: { + dismiss(state, action: PayloadAction<{ id: number }>) { + state.errors = state.errors.filter((e) => e.id !== action.payload.id); + }, + }, + extraReducers: (builder) => { + function handle( + message: string, + ac: AsyncThunk>["rejected"], + ) { + builder.addCase(ac, (state, action) => { + if ( + typeof action.error === "object" && + action.error && + action.error.message + ) { + state.errors.push({ + id: state.lastID++, + message: message, + reason: action.error.message, + }); + } + }); + } + + handle("Failed to update OBS state", updateContinuityScenes.rejected); + handle("Failed to update vMix state", updateLoadState.rejected); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handle("Arbitrary call failed", callArbitrary.rejected as any); + }, +}); + +export const globalErrorReducer = globalErrorSlice.reducer; +export const { dismiss: dismissGlobalError } = globalErrorSlice.actions; diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 5c58bb17f..19a374e19 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1,20 +1,21 @@ import { app, BrowserWindow } from "electron"; import * as path from "path"; -import { createIPCHandler } from "electron-trpc/main"; -import { emitObservable, setSender } from "./ipcEventBus"; -import { appRouter } from "./ipcApi"; -import { tryCreateAPIClient } from "./base/serverApiClient"; -import { tryCreateOBSConnection } from "./obs/obs"; -import { migrateSettings } from "./base/settings"; import isSquirrel from "electron-squirrel-startup"; -import { selectedShow } from "./base/selectedShow"; -import { tryCreateVMixConnection } from "./vmix/vmix"; +import installExtension, { + REACT_DEVELOPER_TOOLS, + REDUX_DEVTOOLS, +} from "electron-devtools-installer"; import Icon from "../icon/png/64x64.png"; -import { tryCreateOntimeConnection } from "./ontime/ontime"; import * as Sentry from "@sentry/electron/main"; import { logFlagState } from "@badger/feature-flags"; import { getLogger } from "./base/logging"; -import { scanLocalMedia } from "./media/mediaManagement"; +import { exposedActionCreators, store } from "./store"; +import { doPreflight } from "./preflight"; +import { listenOnStore } from "./storeListener"; +import { setupStoreIPC } from "./storeIpc"; +import { ipcMain } from "electron/main"; + +setupStoreIPC(store, exposedActionCreators); // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (isSquirrel) { @@ -49,18 +50,10 @@ if (import.meta.env.VITE_DESKTOP_SENTRY_DSN) { } const createWindow = async () => { - logger.debug("Pre-flight..."); - await migrateSettings(); - await Promise.all([ - scanLocalMedia(), - tryCreateAPIClient(), - tryCreateOBSConnection(), - process.platform === "win32" - ? tryCreateVMixConnection() - : Promise.resolve(), - tryCreateOntimeConnection(), - ]); - logger.debug("Pre-flight complete, starting app"); + if (import.meta.env.DEV) { + logger.info("Installing dev tools extensions..."); + await installExtension([REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS]); + } // Create the browser window. const mainWindow = new BrowserWindow({ @@ -81,16 +74,20 @@ const createWindow = async () => { await mainWindow.loadFile(path.join(__dirname, `../renderer/index.html`)); } + listenOnStore({ + predicate: () => true, + effect: (action, api) => { + const state = api.getState(); + mainWindow.webContents.send("stateChange", action.type, state); + }, + }); + // Open the DevTools. if (process.env.BADGER_OPEN_DEVTOOLS === "true") { mainWindow.webContents.openDevTools(); } - - logger.debug("Creating IPC handler..."); - createIPCHandler({ router: appRouter, windows: [mainWindow] }); - setSender(mainWindow.webContents.send.bind(mainWindow.webContents)); - emitObservable("selectedShowChange", selectedShow); - logger.info("Startup complete."); + logger.info("Started, doing preflight..."); + store.dispatch(doPreflight()); }; // This method will be called when Electron has finished @@ -115,5 +112,18 @@ app.on("activate", () => { } }); -// In this file you can include the rest of your app's specific main process -// code. You can also put them in separate files and import them here. +ipcMain.on("devtools-throw-error", () => { + if (!store.getState().settings.devtools.enabled) { + return; + } + process.nextTick(() => { + throw new Error("Test Main Process Exception"); + }); +}); + +ipcMain.on("devtools-crash", () => { + if (!store.getState().settings.devtools.enabled) { + return; + } + process.crash(); +}); diff --git a/desktop/src/main/ipcApi--old.ts b/desktop/src/main/ipcApi--old.ts new file mode 100644 index 000000000..10780c97d --- /dev/null +++ b/desktop/src/main/ipcApi--old.ts @@ -0,0 +1,79 @@ +import { + createAPIClient, + serverAPI, + serverApiClient, +} from "./base/serverApiClient"; +import { z } from "zod"; +import { callProcedure, TRPCError } from "@trpc/server"; +import { selectedShow, setSelectedShow } from "./base/selectedShow"; +import { CompleteShowModel } from "@badger/prisma/utilityTypes"; +import { Integration } from "../common/types"; +import { + devToolsConfigSchema, + getDevToolsConfig, + saveDevToolsConfig, +} from "./base/settings"; +import { IPCEvents } from "./ipcEventBus"; +import { ipcMain } from "electron"; +import logging, { logLevel, setLogLevel } from "./base/logging"; +import { ShowSchema } from "@badger/prisma/types"; +import { inspect } from "node:util"; +import { ontimeRouter } from "./ontime/ipc"; +import { vmixRouter } from "./vmix/ipc"; +import { obsRouter } from "./obs/ipc"; +import { mediaRouter } from "./media/ipc"; +import { proc, r } from "./base/ipcRouter"; +import { + DEV_overrideSupportedIntegrations, + supportedIntegrations, +} from "./base/integrations"; + +const logger = logging.getLogger("ipcApi"); +const rendererLogger = logging.getLogger("renderer"); + +export const appRouter = r({ + log: proc + .input( + z.object({ + level: z.enum(["trace", "debug", "info", "warn", "error"]), + logger: z.string().optional(), + message: z.string(), + }), + ) + .mutation(({ input }) => { + rendererLogger[input.level](input.message); + }), + supportedIntegrations: proc.output(z.array(Integration)).query(() => { + return supportedIntegrations; + }), + getLogLevel: proc + .output(z.enum(["trace", "debug", "info", "warn", "error"])) + .query(() => { + return logLevel; + }), + setLogLevel: proc + .input(z.enum(["trace", "debug", "info", "warn", "error"])) + .mutation(async ({ input }) => { + setLogLevel(input); + }), + media: mediaRouter, + obs: obsRouter, + vmix: vmixRouter, + ontime: ontimeRouter, +}); +export type AppRouter = typeof appRouter; + +if (process.env.E2E_TEST === "true") { + ipcMain.on("doIPCMutation", async (_, proc: string, input: unknown) => { + logger.debug( + "doIPCMutation: " + JSON.stringify(proc) + " " + JSON.stringify(input), + ); + await callProcedure({ + procedures: appRouter._def.procedures, + path: proc, + rawInput: input, + ctx: {}, + type: "mutation", + }); + }); +} diff --git a/desktop/src/main/ipcApi.ts b/desktop/src/main/ipcApi.ts deleted file mode 100644 index 22c8a262c..000000000 --- a/desktop/src/main/ipcApi.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - createAPIClient, - serverAPI, - serverApiClient, -} from "./base/serverApiClient"; -import { z } from "zod"; -import { callProcedure, TRPCError } from "@trpc/server"; -import { selectedShow, setSelectedShow } from "./base/selectedShow"; -import { CompleteShowModel } from "@badger/prisma/utilityTypes"; -import { Integration } from "../common/types"; -import { - devToolsConfigSchema, - getDevToolsConfig, - saveDevToolsConfig, -} from "./base/settings"; -import { IPCEvents } from "./ipcEventBus"; -import { ipcMain } from "electron"; -import logging, { logLevel, setLogLevel } from "./base/logging"; -import { ShowSchema } from "@badger/prisma/types"; -import { inspect } from "node:util"; -import { ontimeRouter } from "./ontime/ipc"; -import { vmixRouter } from "./vmix/ipc"; -import { obsRouter } from "./obs/ipc"; -import { mediaRouter } from "./media/ipc"; -import { proc, r } from "./base/ipcRouter"; -import { - DEV_overrideSupportedIntegrations, - supportedIntegrations, -} from "./base/integrations"; - -const logger = logging.getLogger("ipcApi"); -const rendererLogger = logging.getLogger("renderer"); - -export const appRouter = r({ - serverConnectionStatus: proc - .output( - z.object({ - ok: z.boolean(), - warnings: z - .object({ - versionSkew: z.boolean().optional(), - }) - .optional(), - }), - ) - .query(async () => { - if (serverApiClient === null) { - return { ok: false }; - } - const pingRes = await serverApiClient.ping.query(); - return { - ok: pingRes.ping === "pong", - warnings: { - versionSkew: pingRes.version !== global.__APP_VERSION__, - }, - }; - }), - log: proc - .input( - z.object({ - level: z.enum(["trace", "debug", "info", "warn", "error"]), - logger: z.string().optional(), - message: z.string(), - }), - ) - .mutation(({ input }) => { - rendererLogger[input.level](input.message); - }), - connectToServer: proc - .input( - z.object({ - endpoint: z.string().url(), - password: z.string(), - }), - ) - .mutation(async ({ input }) => { - await createAPIClient(input.endpoint + "/api/trpc", input.password); - return true; - }), - listUpcomingShows: proc.output(z.array(ShowSchema)).query(async () => { - return await serverAPI().shows.listUpcoming.query({ - gracePeriodHours: 24, - }); - }), - getSelectedShow: proc.output(CompleteShowModel.nullable()).query(() => { - logger.trace( - `getSelectedShow called, current value is ${inspect(selectedShow.value)}`, - ); - return selectedShow.value; - }), - setSelectedShow: proc - .input(z.object({ id: z.number() })) - .output(CompleteShowModel) - .mutation(async ({ input }) => { - const data = await serverAPI().shows.get.query({ id: input.id }); - await setSelectedShow(data); - return data; - }), - supportedIntegrations: proc.output(z.array(Integration)).query(() => { - return supportedIntegrations; - }), - getLogLevel: proc - .output(z.enum(["trace", "debug", "info", "warn", "error"])) - .query(() => { - return logLevel; - }), - setLogLevel: proc - .input(z.enum(["trace", "debug", "info", "warn", "error"])) - .mutation(async ({ input }) => { - setLogLevel(input); - }), - devtools: r({ - getSettings: proc - .output(devToolsConfigSchema) - .query(() => getDevToolsConfig()), - setSettings: proc - .input(devToolsConfigSchema) - .mutation(async ({ input }) => { - logger.info("Dev Tools settings change: " + JSON.stringify(input)); - await saveDevToolsConfig(input); - IPCEvents.send("devToolsSettingsChange"); - }), - throwException: proc.mutation(async () => { - if (!(await getDevToolsConfig()).enabled) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Dev tools not enabled", - }); - } - process.nextTick(() => { - throw new Error("Test Main Process Exception"); - }); - }), - crash: proc.mutation(async () => { - if (!(await getDevToolsConfig()).enabled) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Dev tools not enabled", - }); - } - process.crash(); - }), - setEnabledIntegrations: proc - .input(z.array(z.string())) - .mutation(async ({ input }) => { - if (!(await getDevToolsConfig()).enabled) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Dev tools not enabled", - }); - } - DEV_overrideSupportedIntegrations(input as Integration[]); - }), - }), - media: mediaRouter, - obs: obsRouter, - vmix: vmixRouter, - ontime: ontimeRouter, -}); -export type AppRouter = typeof appRouter; - -if (process.env.E2E_TEST === "true") { - ipcMain.on("doIPCMutation", async (_, proc: string, input: unknown) => { - logger.debug( - "doIPCMutation: " + JSON.stringify(proc) + " " + JSON.stringify(input), - ); - await callProcedure({ - procedures: appRouter._def.procedures, - path: proc, - rawInput: input, - ctx: {}, - type: "mutation", - }); - }); -} diff --git a/desktop/src/main/ipcEventBus.ts b/desktop/src/main/ipcEventBus.ts deleted file mode 100644 index 9787c244f..000000000 --- a/desktop/src/main/ipcEventBus.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Events } from "../common/ipcEvents"; -import { Subscribable } from "rxjs"; -import { getLogger } from "./base/logging"; - -const logger = getLogger("ipcEventBus"); - -let sender: (evt: keyof Events, ...args: unknown[]) => void = () => { - throw new Error("sender not initialized"); -}; - -export function setSender(newSender: typeof sender) { - sender = newSender; -} - -export const IPCEvents = { - send(evt: keyof Events, ...args: unknown[]) { - logger.debug(`sending ${evt}`); - sender(evt, ...args); - }, -}; - -export function emitObservable( - evt: K, - obs: Subscribable[0]>, -) { - obs.subscribe({ - next(val) { - logger.debug(`emitting observable ${evt}`); - sender(evt, val); - }, - }); -} diff --git a/desktop/src/main/media/constants.ts b/desktop/src/main/media/constants.ts new file mode 100644 index 000000000..0455866fd --- /dev/null +++ b/desktop/src/main/media/constants.ts @@ -0,0 +1,2 @@ +export const LOCAL_MEDIA_PATH_REGEX = /\(#(\d+)\)/; +export const DOWNLOADING_FILE_SUFFIX = ".badgerdownload"; diff --git a/desktop/src/main/media/downloadFile.ts b/desktop/src/main/media/downloadFile.ts index d54ab93dc..766238ff7 100644 --- a/desktop/src/main/media/downloadFile.ts +++ b/desktop/src/main/media/downloadFile.ts @@ -4,7 +4,6 @@ import * as fs from "fs/promises"; import which from "which"; import invariant from "../../common/invariant"; import logging from "../base/logging"; -import { getDownloadsSettings } from "../base/settings"; import { throttle } from "lodash"; const logger = logging.getLogger("downloadFile"); @@ -108,10 +107,10 @@ export async function downloadFile( url: string, outputPath: string, progress?: (percent: number) => unknown, + downloaderType: "Node" | "Curl" | "Auto" = "Auto", ) { - const settings = await getDownloadsSettings(); let downloader; - switch (settings.downloader) { + switch (downloaderType) { case "Node": downloader = NodeDownloader; break; @@ -128,7 +127,7 @@ export async function downloadFile( : NodeDownloader; break; default: - throw new Error(`Unknown downloader ${settings.downloader}`); + throw new Error(`Unknown downloader ${downloaderType}`); } const downloadPath = outputPath + ".badgerdownload"; await downloader(url, downloadPath, progress); diff --git a/desktop/src/main/media/ipc.ts b/desktop/src/main/media/ipc.ts deleted file mode 100644 index d89f67cae..000000000 --- a/desktop/src/main/media/ipc.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { z } from "zod"; -import * as fsp from "fs/promises"; -import { proc, r } from "../base/ipcRouter"; -import { - DownloadStatusSchema, - deleteMedia, - downloadMedia, - getDownloadStatus, - getLocalMedia, - getMediaPath, -} from "./mediaManagement"; -import { getDownloadsSettings, saveDownloadsSettings } from "../base/settings"; -import { shell } from "electron"; -import { selectedShow } from "../base/selectedShow"; -import { serverAPI } from "../base/serverApiClient"; -import { getLogger } from "../base/logging"; -import { isAfter } from "date-fns"; -import invariant from "../../common/invariant"; -import { getAvailableDownloaders } from "./downloadFile"; -import { getVMixConnection } from "../vmix/vmix"; -import { obsConnection } from "../obs/obs"; - -const logger = getLogger("media/ipc"); - -export const mediaRouter = r({ - getDownloadStatus: proc - .output(z.array(DownloadStatusSchema)) - .query(() => getDownloadStatus()), - downloadMedia: proc - .input(z.object({ id: z.number(), name: z.string().optional() })) - .mutation(async ({ input }) => { - downloadMedia(input.id, input.name); - }), - getLocalMedia: proc - .input( - z - .object({ - includeSize: z.boolean().default(false), - }) - .optional(), - ) - .output( - z.array( - z.object({ - mediaID: z.number(), - path: z.string(), - sizeBytes: z.number().optional(), - }), - ), - ) - .query(async ({ input }) => { - const media = getLocalMedia(); - if (input?.includeSize) { - await Promise.all( - media.map(async (item) => { - let stat; - try { - stat = await fsp.stat(item.path); - } catch (e) { - // This is an orphan. TODO [BDGR-67]: we don't currently handle them - return; - } - (item as unknown as { sizeBytes: number }).sizeBytes = stat.size; - }), - ); - } - return media; - }), - getPath: proc.output(z.string()).query(() => getMediaPath()), - openPath: proc.mutation(async () => { - await shell.openPath(await getMediaPath()); - }), - delete: proc - .input(z.object({ id: z.number() })) - .mutation(async ({ input }) => { - await deleteMedia(input.id); - }), - deleteOldMedia: proc - .input(z.object({ minAgeDays: z.number() })) - .mutation(async ({ input }) => { - const localMedia = getLocalMedia(); - const currentShow = selectedShow.value; - let notInUse; - if (currentShow) { - const inUse = new Set(); - currentShow?.continuityItems.forEach((x) => { - if (x.media) { - inUse.add(x.media.id); - } - }); - currentShow?.rundowns.forEach((x) => - x.items.forEach((y) => { - if (y.media) { - inUse.add(y.media.id); - } - }), - ); - notInUse = localMedia.filter((x) => !inUse.has(x.mediaID)); - } else { - notInUse = localMedia; - } - const mediaObjects = await serverAPI().media.bulkGet.query( - notInUse.map((x) => x.mediaID), - ); - for (const result of mediaObjects) { - logger.debug("Deletion candidate", result); - if (result.state !== "Ready") { - continue; - } - const latestShowDate = [ - result.rundownItems.map((x) => x.rundown.show.start), - result.continuityItems.map((x) => x.show.start), - result.assets.map((x) => x.rundown.show.start), - ] - .flat() - .reduce((a, b) => (isAfter(a, b) ? a : b), new Date(0)); - invariant( - latestShowDate.getTime() !== 0, - "no rundown, continuity item, or asset for media " + result.id, - ); - const age = - (Date.now() - latestShowDate.getTime()) / (1000 * 60 * 60 * 24); - logger.debug( - result.id, - result.name, - "age", - age, - "threshold", - input.minAgeDays, - ); - if (age > input.minAgeDays) { - logger.debug("Deleting", result.id); - await deleteMedia(result.id); - } - } - }), - downloadAllMediaForSelectedShow: proc.mutation(async () => { - const show = selectedShow.value; - invariant(show, "No show selected"); - const state = getLocalMedia(); - // TODO[BDGR-136]: Rather than checking for the connection, split out supportedIntegrations and enabledIntegrations and check the latter - if (getVMixConnection() !== null) { - for (const rundown of show.rundowns) { - for (const item of rundown.items) { - if ( - item.media?.state === "Ready" && - !state.some((x) => x.mediaID === item.media?.id) - ) { - downloadMedia(item.media.id, item.media.name); - } - } - for (const item of rundown.assets) { - if ( - item.media?.state === "Ready" && - !state.some((x) => x.mediaID === item.media?.id) - ) { - downloadMedia(item.media.id, item.media.name); - } - } - } - } - // TODO[BDGR-136]: Rather than checking for the connection, split out supportedIntegrations and enabledIntegrations and check the latter - if (obsConnection !== null) { - for (const item of show.continuityItems) { - if ( - item.media?.state === "Ready" && - !state.some((x) => x.mediaID === item.media?.id) - ) { - downloadMedia(item.media.id, item.media.name); - } - } - } - }), - getAvailableDownloaders: proc - .output(z.array(z.enum(["Auto", "Node", "Curl"]))) - .query(async () => { - return ["Auto", ...(await getAvailableDownloaders())]; - }), - getSelectedDownloader: proc - .output(z.enum(["Auto", "Node", "Curl"])) - .query(async () => { - const settings = await getDownloadsSettings(); - return settings.downloader; - }), - setSelectedDownloader: proc - .input(z.enum(["Auto", "Node", "Curl"])) - .mutation(async ({ input }) => { - await saveDownloadsSettings({ downloader: input }); - }), -}); diff --git a/desktop/src/main/media/mediaManagement.ts b/desktop/src/main/media/mediaManagement.ts index 37a0740c2..f23001f0b 100644 --- a/desktop/src/main/media/mediaManagement.ts +++ b/desktop/src/main/media/mediaManagement.ts @@ -1,24 +1,23 @@ // noinspection ExceptionCaughtLocallyJS import * as os from "node:os"; -import { getMediaSettings } from "../base/settings"; import * as fsp from "fs/promises"; import * as path from "path"; -import { serverApiClient } from "../base/serverApiClient"; -import { z } from "zod"; -import { IPCEvents } from "../ipcEventBus"; +import { serverAPI } from "../base/serverApiClient"; import { downloadFile } from "./downloadFile"; import logging from "../base/logging"; +import { DOWNLOADING_FILE_SUFFIX, LOCAL_MEDIA_PATH_REGEX } from "./constants"; +import invariant from "../../common/invariant"; -const logger = logging.getLogger("mediaManagement"); +export interface LocalMediaItem { + mediaID: number; + path: string; + sizeBytes: number; +} -const LOCAL_MEDIA_PATH_REGEX = /\(#(\d+)\)/; +const logger = logging.getLogger("mediaManagement"); -export async function getMediaPath(): Promise { - const settings = await getMediaSettings(); - if (settings) { - return settings.mediaPath; - } +export function getDefaultMediaPath() { switch (os.platform()) { case "win32": return "C:\\badger_media"; @@ -31,162 +30,79 @@ export async function getMediaPath(): Promise { } } -export async function ensureMediaPath(): Promise { - const mediaPath = await getMediaPath(); - await fsp.mkdir(mediaPath, { recursive: true }); - return mediaPath; -} - -const downloadedMedia = new Map(); - -export function getLocalMedia() { - return Array.from(downloadedMedia.entries()).map(([mediaID, path]) => ({ - mediaID, - path, - })); -} - -export async function scanLocalMedia() { - logger.info("Scanning local media..."); - await ensureMediaPath(); - const files = await fsp.readdir(await getMediaPath()); - for (const file of files) { - if (file.endsWith(".badgerdownload")) { - continue; // pending download - } - const name = file.replace(path.extname(file), ""); - const match = name.match(LOCAL_MEDIA_PATH_REGEX); - if (!match) { - continue; - } - downloadedMedia.set( - Number(match[1]), - path.join(await getMediaPath(), file), - ); - } - logger.info(`Finished local media scan, found ${downloadedMedia.size} items`); - // TODO[BDGR-67]: Check if any of these are orphans and delete them -} - -interface DownloadQueueItem { - mediaID: number; -} - -export const DownloadStatusSchema = z.object({ - mediaID: z.number(), - name: z.string(), - status: z.enum(["pending", "downloading", "done", "error"]), - progressPercent: z.number().optional(), - error: z.string().optional(), -}); -type DownloadStatus = z.infer; - -const downloadQueue: DownloadQueueItem[] = []; -const downloadStatus: Map = new Map(); -let isDownloadRunning = false; +export type MediaDownloadState = "pending" | "downloading" | "done" | "error"; + +export async function doDownloadMedia( + task: { mediaID: number }, + mediaPath: string, + onProgress: ( + state: MediaDownloadState, + progress: number, + fileName: string, + ) => void, +) { + const serverApiClient = serverAPI(); + const info = await serverApiClient.media.get.query({ id: task.mediaID }); + const urlRaw = info.downloadURL; + invariant( + urlRaw, + `Requested to download media ${info.id} [${info.name}], but it did not have a download URL.`, + ); + + // Transforms "foo.mp4" to "foo (#123).mp4" + const extension = path.extname(info.name); + const newFileName = + info.name.slice(0, -extension.length) + ` (#${info.id})` + extension; + const outputPath = path.join(mediaPath, newFileName); + logger.info( + `Starting to download media ${info.id} [${newFileName}] to ${outputPath}`, + ); + onProgress("downloading", 0, newFileName); -async function doDownloadMedia() { - if (isDownloadRunning) { - return; - } - // this is JS, no atomicity needed! - isDownloadRunning = true; try { - const task = downloadQueue.shift(); - if (!task) { - return; - } - if (!serverApiClient) { - throw new Error("Server API client not initialized"); - } - const info = await serverApiClient.media.get.query({ id: task.mediaID }); - const urlRaw = info.downloadURL; - if (!urlRaw) { - logger.warn( - `Requested to download media ${info.id} [${info.name}], but it did not have a download URL.`, - ); - process.nextTick(doDownloadMedia); - return; - } - - // Transforms "foo.mp4" to "foo (#123).mp4" - const extension = path.extname(info.name); - const newFileName = - info.name.slice(0, -extension.length) + ` (#${info.id})` + extension; - const outputPath = path.join(await ensureMediaPath(), newFileName); - logger.info( - `Starting to download media ${info.id} [${newFileName}] to ${outputPath}`, - ); - const status: DownloadStatus = { - mediaID: info.id, - name: newFileName, - status: "downloading", - progressPercent: 0, - }; - downloadStatus.set(info.id, status); - IPCEvents.send("downloadStatusChange"); - - try { - await downloadFile(urlRaw, outputPath, (progress: number) => { - status.progressPercent = progress; - downloadStatus.set(info.id, status); - IPCEvents.send("downloadStatusChange"); - }); - } catch (e) { - logger.error(`Error downloading media ${info.id} [${newFileName}]`, e); - status.status = "error"; - status.error = String(e); - if (downloadQueue.length > 0) { - process.nextTick(doDownloadMedia); - } - IPCEvents.send("downloadStatusChange"); - return; - } - - logger.info( - `Downloaded media ${info.id} [${newFileName}] to ${outputPath}`, - ); - status.status = "done"; - status.progressPercent = 100; - downloadStatus.set(info.id, status); - - downloadedMedia.set(info.id, outputPath); - IPCEvents.send("downloadStatusChange"); - IPCEvents.send("localMediaStateChange"); - logger.trace("IPC sent"); - - if (downloadQueue.length > 0) { - process.nextTick(doDownloadMedia); - } - } finally { - isDownloadRunning = false; + await downloadFile(urlRaw, outputPath, (progress: number) => { + onProgress("downloading", progress, newFileName); + }); + } catch (e) { + throw new Error(`Failed to download media ${info.id} [${newFileName}]`, { + cause: e, + }); } -} -export function downloadMedia(mediaID: number, name?: string) { - downloadQueue.push({ mediaID }); - downloadStatus.set(mediaID, { - mediaID, - // NB: This is technically untrusted data, as it's passed in from the renderer. However, - // this is safe, as this is only used for display purposes - the actual file name is determined - // (and this is overwritten) in doDownloadMedia after doing a server API fetch. - name: name ?? "Unknown", - status: "pending", - }); - process.nextTick(doDownloadMedia); + logger.info(`Downloaded media ${info.id} [${newFileName}] to ${outputPath}`); + onProgress("done", 100, newFileName); + const stat = await fsp.stat(outputPath); + return { + outputPath, + sizeBytes: stat.size, + }; } -export function getDownloadStatus() { - return Array.from(downloadStatus.values()); -} +export async function scanLocalMedia(mediaPath: string) { + logger.info("Scanning local media..."); -export async function deleteMedia(mediaID: number) { - const path = downloadedMedia.get(mediaID); - if (!path) { - throw new Error(`Media ${mediaID} not found`); - } - downloadedMedia.delete(mediaID); - await fsp.unlink(path); - logger.info("Deleted", path); - IPCEvents.send("localMediaStateChange"); + const files = await fsp.readdir(mediaPath); + const result: LocalMediaItem[] = ( + await Promise.all( + files.map(async (file) => { + if (file.endsWith(DOWNLOADING_FILE_SUFFIX)) { + return null; + } + const name = file.replace(path.extname(file), ""); + const match = name.match(LOCAL_MEDIA_PATH_REGEX); + if (!match) { + return null; + } + const stat = await fsp.stat(path.join(mediaPath, file)); + return { + mediaID: parseInt(match[1], 10), + path: file, + sizeBytes: stat.size, + }; + }), + ) + ).filter((v) => !!v); + logger.info(`Finished local media scan, found ${result.length} items`); + // TODO[BDGR-67]: Check if any of these are orphans and delete them + return result; } diff --git a/desktop/src/main/media/state.ts b/desktop/src/main/media/state.ts new file mode 100644 index 000000000..6e0af42e1 --- /dev/null +++ b/desktop/src/main/media/state.ts @@ -0,0 +1,298 @@ +import * as fsp from "fs/promises"; + +import { createAppSlice } from "../base/reduxHelpers"; +import { + doDownloadMedia, + getDefaultMediaPath, + LocalMediaItem, + MediaDownloadState, + scanLocalMedia, +} from "./mediaManagement"; +import { AppState } from "../store"; +import { setSetting } from "../base/settings"; +import { createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import { listenOnStore } from "../storeListener"; +import invariant from "../../common/invariant"; +import { getSelectedShow } from "../base/selectedShow"; +import { serverAPI } from "../base/serverApiClient"; +import { getLogger } from "../base/logging"; +import { isAfter } from "date-fns"; + +const logger = getLogger("localMedia/state"); + +interface DownloadQueueItem { + mediaID: number; + name?: string; + status: MediaDownloadState; + progressPercent?: number; + error?: string; +} + +const initialise = createAsyncThunk( + "localMedia/loadLocalMedia", + async (_, thunkAPI) => { + const settings = (thunkAPI.getState() as AppState).settings; + let mediaPath = settings.media.mediaPath; + if (!mediaPath) { + mediaPath = getDefaultMediaPath(); + thunkAPI.dispatch(setSetting("media", "mediaPath", mediaPath)); + } + await fsp.mkdir(mediaPath, { recursive: true }); + const media = await scanLocalMedia(mediaPath); + return media; + }, +); + +const downloadMedia = createAsyncThunk( + "localMedia/downloadMedia", + async (task: { mediaID: number }, thunkAPI) => { + const state = thunkAPI.getState() as AppState; + const mediaPath = state.settings.media.mediaPath; + if (!mediaPath) { + throw new Error("Media path not set"); + } + const { outputPath, sizeBytes } = await doDownloadMedia( + task, + mediaPath, + (state, progress, name) => { + thunkAPI.dispatch( + localMediaState.actions.downloadProgress({ + mediaID: task.mediaID, + state, + progress, + name, + }), + ); + }, + ); + return { mediaID: task.mediaID, path: outputPath, sizeBytes }; + }, +); + +const deleteOldMedia = createAsyncThunk( + "localMedia/deleteOldMedia", + async (payload: { minAgeDays: number }, thunkAPI) => { + const state = thunkAPI.getState() as AppState; + const localMedia = state.localMedia.media; + const currentShow = state.selectedShow.show; + let notInUse; + if (currentShow) { + const inUse = new Set(); + currentShow?.continuityItems.forEach((x) => { + if (x.media) { + inUse.add(x.media.id); + } + }); + currentShow?.rundowns.forEach((x) => + x.items.forEach((y) => { + if (y.media) { + inUse.add(y.media.id); + } + }), + ); + notInUse = localMedia.filter((x) => !inUse.has(x.mediaID)); + } else { + notInUse = localMedia; + } + const mediaObjects = await serverAPI().media.bulkGet.query( + notInUse.map((x) => x.mediaID), + ); + const deletedIDs = []; + for (const result of mediaObjects) { + logger.debug("Deletion candidate", result); + if (result.state !== "Ready") { + continue; + } + const latestShowDate = [ + result.rundownItems.map((x) => x.rundown.show.start), + result.continuityItems.map((x) => x.show.start), + result.assets.map((x) => x.rundown.show.start), + ] + .flat() + .reduce((a, b) => (isAfter(a, b) ? a : b), new Date(0)); + invariant( + latestShowDate.getTime() !== 0, + "no rundown, continuity item, or asset for media " + result.id, + ); + const age = + (Date.now() - latestShowDate.getTime()) / (1000 * 60 * 60 * 24); + logger.debug( + result.id, + result.name, + "age", + age, + "threshold", + payload.minAgeDays, + ); + if (age > payload.minAgeDays) { + logger.debug("Deleting", result.id); + const path = localMedia.find((x) => x.mediaID === result.id)?.path; + if (!path) { + throw new Error(`Media ${result.id} not found`); + } + deletedIDs.push(result.id); + // TODO: We delete it on disk before removing it from state. In theory this is + // safe because of the notInUse check, but it's a bit risky. We should remove + // it from the state, then delete from disk. + await fsp.unlink(path); + logger.info("Deleted", path); + } + } + return deletedIDs; + }, +); + +const localMediaState = createAppSlice({ + name: "localMedia", + initialState: { + media: [] as LocalMediaItem[], + downloadQueue: [] as DownloadQueueItem[], + currentDownload: null as DownloadQueueItem | null, + failedDownloads: [] as DownloadQueueItem[], + }, + reducers: { + queueMediaDownload(state, action: PayloadAction<{ mediaID: number }>) { + state.downloadQueue.push({ + mediaID: action.payload.mediaID, + status: "pending", + }); + if (state.currentDownload === null) { + state.currentDownload = state.downloadQueue.shift() ?? null; + } + }, + queueMediaDownloads(state, action: PayloadAction<{ mediaIDs: number[] }>) { + const alreadyPresent = new Set( + state.downloadQueue + .map((i) => i.mediaID) + .concat(state.media.map((i) => i.mediaID)), + ); + for (const mediaID of action.payload.mediaIDs) { + if (!alreadyPresent.has(mediaID)) { + state.downloadQueue.push({ + mediaID, + status: "pending", + }); + } + } + if (state.currentDownload === null) { + state.currentDownload = state.downloadQueue.shift() ?? null; + } + }, + downloadAllMediaForSelectedShow(state) { + const show = getSelectedShow(); + if (show === null) { + return; + } + // This is all the media IDs for the current show + const mediaIDs: number[] = []; + for (const rundown of show.rundowns) { + for (const item of rundown.items) { + if (item.media) { + mediaIDs.push(item.media.id); + } + } + for (const asset of rundown.assets) { + mediaIDs.push(asset.media.id); + } + } + for (const continuityItem of show.continuityItems) { + if (continuityItem.media) { + mediaIDs.push(continuityItem.media.id); + } + } + // All media that is either already downloaded or in the queue + const alreadyPresent = new Set( + state.downloadQueue + .map((i) => i.mediaID) + .concat(state.media.map((i) => i.mediaID)), + ); + // Enqueue all the media that isn't already present + for (const mediaID of mediaIDs) { + if (!alreadyPresent.has(mediaID)) { + state.downloadQueue.push({ + mediaID, + status: "pending", + }); + } + } + }, + downloadProgress( + state, + action: PayloadAction<{ + mediaID: number; + state: MediaDownloadState; + progress: number; + name?: string; + }>, + ) { + invariant(state.currentDownload, "No current download"); + invariant( + state.currentDownload.mediaID === action.payload.mediaID, + "Progress for unexpected media ID", + ); + state.currentDownload.status = action.payload.state; + state.currentDownload.progressPercent = action.payload.progress; + state.currentDownload.name = action.payload.name; + }, + }, + extraReducers: (builder) => { + builder.addCase(initialise.fulfilled, (state, action) => { + state.media = action.payload; + }); + builder.addCase(downloadMedia.fulfilled, (state, action) => { + invariant(state.currentDownload, "No current download"); + invariant( + state.currentDownload.mediaID === action.payload.mediaID, + "Fulfilled for unexpected media ID", + ); + state.media.push({ + mediaID: action.payload.mediaID, + path: action.payload.path, + sizeBytes: action.payload.sizeBytes, + }); + state.currentDownload = state.downloadQueue.shift() ?? null; + }); + builder.addCase(downloadMedia.rejected, (state, action) => { + invariant(state.currentDownload, "No current download"); + invariant( + state.currentDownload.mediaID === action.meta.arg.mediaID, + "Fulfilled for unexpected media ID", + ); + state.failedDownloads.push({ + mediaID: action.meta.arg.mediaID, + status: "error", + error: action.error.message, + }); + state.currentDownload = state.downloadQueue.shift() ?? null; + }); + builder.addCase(deleteOldMedia.fulfilled, (state, action) => { + state.media = state.media.filter( + (x) => !action.payload.includes(x.mediaID), + ); + }); + }, +}); + +// This effect handles starting downloads when they are queued +listenOnStore({ + predicate: (_, oldState, newState) => + oldState.localMedia.currentDownload?.mediaID !== + newState.localMedia.currentDownload?.mediaID, + effect: (_, api) => { + const state = api.getState() as AppState; + const nextItem = state.localMedia.currentDownload; + if (!nextItem) { + return; + } + api.dispatch(downloadMedia({ mediaID: nextItem.mediaID })); + }, +}); + +export const localMediaReducer = localMediaState.reducer; +export const localMediaActions = { + initialise, + queueMediaDownload: localMediaState.actions.queueMediaDownload, + downloadAllMediaForSelectedShow: + localMediaState.actions.downloadAllMediaForSelectedShow, + deleteOldMedia, +}; diff --git a/desktop/src/main/obs/constants.ts b/desktop/src/main/obs/constants.ts new file mode 100644 index 000000000..6f8551984 --- /dev/null +++ b/desktop/src/main/obs/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_OBS_PORT = 4455; diff --git a/desktop/src/main/obs/ipc.ts b/desktop/src/main/obs/ipc.ts deleted file mode 100644 index 05a3e07d7..000000000 --- a/desktop/src/main/obs/ipc.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { z } from "zod"; -import { proc, r } from "../base/ipcRouter"; -import { createOBSConnection, obsConnection } from "./obs"; -import { addOrReplaceMediaAsScene, findContinuityScenes } from "./obsHelpers"; -import { getLogger } from "loglevel"; -import { serverAPI } from "../base/serverApiClient"; -import invariant from "../../common/invariant"; -import { selectedShow } from "../base/selectedShow"; -import { getDevToolsConfig } from "../base/settings"; -import { TRPCError } from "@trpc/server"; -import { getLocalMedia } from "../media/mediaManagement"; - -const logger = getLogger("obs/ipc"); - -export const obsRouter = r({ - getConnectionState: proc - .output( - z.object({ - connected: z.boolean(), - version: z.string().optional(), - platform: z.string().optional(), - error: z.string().optional(), - availableRequests: z.array(z.string()).optional(), - }), - ) - .query(async () => { - // TODO[BDGR-136]: don't use the connection for this - if (obsConnection === null) { - return { connected: false }; - } - try { - const version = await obsConnection.ping(); - return { - connected: true, - version: version.obsVersion, - platform: version.platformDescription, - availableRequests: version.availableRequests, - }; - } catch (e) { - logger.warn("OBS connection error", e); - return { connected: false, error: String(e) }; - } - }), - connect: proc - .input( - z.object({ - host: z.string(), - port: z.coerce.number(), - password: z.string(), - }), - ) - .mutation(async ({ input }) => { - await createOBSConnection(input.host, input.password, input.port); - }), - addMediaAsScene: proc - .input( - z.object({ - id: z.number(), - replaceMode: z.enum(["none", "replace", "force"]).default("none"), - }), - ) - .output( - z.object({ - done: z.boolean(), - warnings: z.array(z.string()), - promptReplace: z.enum(["replace", "force"]).optional(), - }), - ) - .mutation(async ({ input }) => { - const info = await serverAPI().media.get.query({ id: input.id }); - invariant( - info.continuityItems.length > 0, - "obs.addMediaAsScene: No continuity item for media in obs.addMediaAsScene", - ); - return await addOrReplaceMediaAsScene(info, input.replaceMode); - }), - addAllSelectedShowMedia: proc - .output( - z.object({ - done: z.number(), - warnings: z.array(z.string()), - }), - ) - .mutation(async () => { - const show = selectedShow.value; - invariant(show, "No show selected"); - const state = getLocalMedia(); - let done = 0; - const warnings: string[] = []; - for (const item of show.continuityItems) { - if ( - item.media && - item.media.state === "Ready" && - state.some((x) => x.mediaID === item.media!.id) - ) { - const r = await addOrReplaceMediaAsScene( - { - ...item.media, - continuityItems: [item], - }, - "replace", - ); - if (r.done) { - done++; - } else if (r.warnings.length > 0) { - warnings.push(item.name + ": " + r.warnings.join(" ")); - } - } - } - return { done, warnings }; - }), - listContinuityItemScenes: proc - .output( - z.array( - z.object({ - sceneName: z.string(), - continuityItemID: z.number(), - sources: z.array( - z.object({ - mediaID: z.number().optional(), - }), - ), - }), - ), - ) - .query(async () => { - return await findContinuityScenes(); - }), - dev: r({ - callArbitrary: proc - .input(z.object({ req: z.string(), params: z.any() })) - .output(z.any()) - .mutation(async ({ input }) => { - const dtSettings = await getDevToolsConfig(); - if (!dtSettings.enabled) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Dev tools not enabled", - }); - } - invariant(obsConnection, "no OBS connection"); - return await obsConnection.callArbitraryDoNotUseOrYouWillBeFired( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - input.req as any, - input.params, - ); - }), - }), -}); diff --git a/desktop/src/main/obs/obs.ts b/desktop/src/main/obs/obs.ts index 73b2e6e7b..684df0a84 100644 --- a/desktop/src/main/obs/obs.ts +++ b/desktop/src/main/obs/obs.ts @@ -2,11 +2,9 @@ import OBSWebSocket, { OBSRequestTypes, OBSResponseTypes, } from "obs-websocket-js"; -import { getOBSSettings, saveOBSSettings } from "../base/settings"; import { getLogger } from "../base/logging"; import { inspect } from "node:util"; - -const logger = getLogger("obs"); +import { DEFAULT_OBS_PORT } from "./constants"; /* * This file contains OBSConnection, a wrapper around obs-websocket-js that provides a higher level, more typesafe API. @@ -286,30 +284,10 @@ export let obsConnection: OBSConnection | null = null; export async function createOBSConnection( obsHost: string, obsPassword: string, - obsPort = 4455, + obsPort = DEFAULT_OBS_PORT, ) { - obsConnection = await OBSConnection.create(obsHost, obsPassword, obsPort); - await obsConnection.ping(); - await saveOBSSettings({ - host: obsHost, - password: obsPassword, - port: obsPort, - }); - return obsConnection; -} - -export async function tryCreateOBSConnection() { - const settings = await getOBSSettings(); - if (settings !== null) { - try { - obsConnection = await OBSConnection.create( - settings.host, - settings.password, - settings.port, - ); - logger.info("Successfully connected to OBS using saved credentials"); - } catch (e) { - logger.warn("Failed to connect to OBS (will ignore)", e); - } - } + const oc = await OBSConnection.create(obsHost, obsPassword, obsPort); + const pingResult = await oc.ping(); + obsConnection = oc; + return pingResult; } diff --git a/desktop/src/main/obs/obsHelpers.test.ts b/desktop/src/main/obs/obsHelpers.test.ts index acf1e7ab8..63c33c156 100644 --- a/desktop/src/main/obs/obsHelpers.test.ts +++ b/desktop/src/main/obs/obsHelpers.test.ts @@ -5,31 +5,27 @@ import { MediaType, } from "./obsHelpers"; import { MockOBSConnection } from "./__mocks__/obs"; +import { CompleteShowType } from "../../common/types"; +import { LocalMediaItem } from "../media/mediaManagement"; vi.mock("./obs"); -vi.mock("../media/mediaManagement", () => ({ - getLocalMedia: () => [ - { - mediaID: 1, - path: "TEST_PATH", - }, - ], -})); -vi.mock("../base/selectedShow", async () => { - const { BehaviorSubject } = await import("rxjs"); - return { - selectedShow: new BehaviorSubject({ - id: 1, - name: "Test", - start: new Date(), - rundowns: [], - continuityItems: [], - version: 1, - ytBroadcastID: null, - ytStreamID: null, - }), - }; -}); +const localMedia: LocalMediaItem[] = [ + { + mediaID: 1, + path: "TEST_PATH", + sizeBytes: 0, + }, +]; +const selectedShow: CompleteShowType = { + id: 1, + name: "Test", + start: new Date(), + rundowns: [], + continuityItems: [], + version: 1, + ytBroadcastID: null, + ytStreamID: null, +}; describe("addOrReplaceMediaAsScene", () => { const testMedia: MediaType = { @@ -61,7 +57,12 @@ describe("addOrReplaceMediaAsScene", () => { }); test("add with no scenes", async () => { - const res = await addOrReplaceMediaAsScene(testMedia, "none"); + const res = await addOrReplaceMediaAsScene( + testMedia, + "none", + selectedShow, + localMedia, + ); expect(res).toEqual({ done: true, warnings: [], @@ -93,7 +94,12 @@ describe("addOrReplaceMediaAsScene", () => { }, ], }); - const res = await addOrReplaceMediaAsScene(testMedia, "none"); + const res = await addOrReplaceMediaAsScene( + testMedia, + "none", + selectedShow, + localMedia, + ); expect(res).toEqual({ done: false, warnings: [], @@ -106,7 +112,12 @@ describe("addOrReplaceMediaAsScene", () => { name: "1 - Test Continuity [#1]", sources: [], }); - const res = await addOrReplaceMediaAsScene(testMedia, "none"); + const res = await addOrReplaceMediaAsScene( + testMedia, + "none", + selectedShow, + localMedia, + ); expect(res).toEqual({ done: true, warnings: [], @@ -126,7 +137,12 @@ describe("addOrReplaceMediaAsScene", () => { }, ], }); - const res = await addOrReplaceMediaAsScene(testMedia, "none"); + const res = await addOrReplaceMediaAsScene( + testMedia, + "none", + selectedShow, + localMedia, + ); expect(res).toMatchInlineSnapshot(` { "done": false, @@ -138,7 +154,12 @@ describe("addOrReplaceMediaAsScene", () => { `); expect(mobs.scenes[0].sources[0].inputName).toBe("Badger Media 999"); - const res2 = await addOrReplaceMediaAsScene(testMedia, "replace"); + const res2 = await addOrReplaceMediaAsScene( + testMedia, + "replace", + selectedShow, + localMedia, + ); expect(res2).toEqual({ done: true, warnings: [], @@ -163,7 +184,12 @@ describe("addOrReplaceMediaAsScene", () => { }, ], }); - const res = await addOrReplaceMediaAsScene(testMedia, "none"); + const res = await addOrReplaceMediaAsScene( + testMedia, + "none", + selectedShow, + localMedia, + ); expect(res).toMatchInlineSnapshot(` { "done": false, @@ -174,7 +200,12 @@ describe("addOrReplaceMediaAsScene", () => { } `); - const res2 = await addOrReplaceMediaAsScene(testMedia, "force"); + const res2 = await addOrReplaceMediaAsScene( + testMedia, + "force", + selectedShow, + localMedia, + ); expect(res2).toEqual({ done: true, warnings: [], diff --git a/desktop/src/main/obs/obsHelpers.ts b/desktop/src/main/obs/obsHelpers.ts index 7a1dfea8e..aa00d1fff 100644 --- a/desktop/src/main/obs/obsHelpers.ts +++ b/desktop/src/main/obs/obsHelpers.ts @@ -7,8 +7,8 @@ import { import invariant from "../../common/invariant"; import type { Media, ContinuityItem } from "@badger/prisma/types"; import { getLogger } from "../base/logging"; -import { selectedShow } from "../base/selectedShow"; -import { getLocalMedia } from "../media/mediaManagement"; +import { CompleteShowType } from "../../common/types"; +import { LocalMediaItem } from "../media/mediaManagement"; const logger = getLogger("obsHelpers"); @@ -39,6 +39,8 @@ export const CONTINUITY_SCENE_NAME_REGEXP = /^\d+ - .+? \[#(\d+)]$/; export async function addOrReplaceMediaAsScene( info: MediaType, replaceMode: "none" | "replace" | "force", + selectedShow: CompleteShowType, + localMedia: LocalMediaItem[], ): Promise<{ done: boolean; warnings: string[]; @@ -55,7 +57,6 @@ export async function addOrReplaceMediaAsScene( "addOrReplaceMediaAsScene: No continuity item for media in addMediaAsScene", ); invariant(obsConnection, "no OBS connection"); - const localMedia = getLocalMedia(); const item = localMedia.find((x) => x.mediaID === info.id); invariant( item !== undefined, @@ -66,7 +67,7 @@ export async function addOrReplaceMediaAsScene( const mediaSourceName = MEDIA_SOURCE_PREFIX + info.id.toString(10); const currentContinuityItem = info.continuityItems.find( - (x) => x.showId === selectedShow.value!.id, + (x) => x.showId === selectedShow.id, ); invariant( currentContinuityItem, diff --git a/desktop/src/main/obs/state.ts b/desktop/src/main/obs/state.ts new file mode 100644 index 000000000..855649af8 --- /dev/null +++ b/desktop/src/main/obs/state.ts @@ -0,0 +1,192 @@ +import { createAsyncThunk, isAnyOf } from "@reduxjs/toolkit"; +import { createAppSlice } from "../base/reduxHelpers"; +import { createOBSConnection, obsConnection } from "./obs"; +import { AppState } from "../store"; +import { getLogger } from "../base/logging"; +import { addOrReplaceMediaAsScene, findContinuityScenes } from "./obsHelpers"; +import { listenOnStore } from "../storeListener"; +import { serverAPI } from "../base/serverApiClient"; +import invariant from "../../common/invariant"; +import { OBSRequestTypes, OBSResponseTypes } from "obs-websocket-js"; + +const logger = getLogger("obs/state"); + +export const obsSlice = createAppSlice({ + name: "obs", + initialState: { + connection: { + connected: false, + connecting: false, + version: "", + platform: "", + availableRequests: [] as string[], + error: null as string | null, + }, + continuityScenes: [] as { + continuityItemID: number; + sources: { mediaID?: number }[]; + }[], + arbitraryCallResult: null as + | null + | OBSResponseTypes[keyof OBSResponseTypes], + }, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(connectToOBS.pending, (state) => { + state.connection.connecting = true; + }); + builder.addCase(connectToOBS.rejected, (state, action) => { + state.connection.error = action.error.message ?? "Unknown error"; + state.connection.connecting = false; + }); + + builder.addCase(updateContinuityScenes.fulfilled, (state, action) => { + state.continuityScenes = action.payload.map((s) => ({ + continuityItemID: s.continuityItemID, + sources: s.sources.map((src) => ({ mediaID: src.mediaID })), + })); + }); + // rejected handled in globalError + + builder.addCase(addContinuityItemAsScene.fulfilled, (state, action) => { + if (action.payload.newScenes) { + state.continuityScenes = action.payload.newScenes.map((s) => ({ + continuityItemID: s.continuityItemID, + sources: s.sources.map((src) => ({ mediaID: src.mediaID })), + })); + } + }); + + builder.addCase(callArbitrary.fulfilled, (state, action) => { + // @ts-expect-error type too complex + state.arbitraryCallResult = action.payload; + }); + + builder.addMatcher( + isAnyOf(connectToOBS.fulfilled, tryConnectToOBS.fulfilled), + (state, data) => { + if (data.payload) { + state.connection.connected = true; + state.connection.connecting = false; + state.connection.version = data.payload.obsVersion; + state.connection.platform = data.payload.platformDescription; + state.connection.availableRequests = data.payload.availableRequests; + } + }, + ); + }, +}); + +export const connectToOBS = createAsyncThunk( + "obs/connect", + async (payload: { host: string; password: string; port?: number }) => { + return await createOBSConnection( + payload.host, + payload.password, + payload.port, + ); + }, +); + +export const tryConnectToOBS = createAsyncThunk( + "obs/tryConnect", + async (_, api) => { + const settings = (api.getState() as AppState).settings.obs; + if (!settings.host || !settings.password) { + logger.info("No OBS settings, skipping connection attempt"); + return; + } + try { + const r = await createOBSConnection( + settings.host, + settings.password, + settings.port, + ); + logger.info("Connected to OBS using saved credentials"); + return r; + } catch (e) { + logger.info(`Failed to connect to OBS using saved credentials: ${e}`); + return; + } + }, +); + +export const callArbitrary = createAsyncThunk( + "obs/callArbitrary", + async ( + payload: { + req: keyof OBSRequestTypes; + data?: OBSRequestTypes[keyof OBSRequestTypes]; + }, + api, + ) => { + const state = api.getState() as AppState; + if (!state.settings.devtools.enabled) { + return api.rejectWithValue("Dev tools are disabled"); + } + invariant(obsConnection, "OBS connection not initialized"); + const res = await obsConnection.callArbitraryDoNotUseOrYouWillBeFired( + payload.req, + payload.data, + ); + return res; + }, +); + +export const updateContinuityScenes = createAsyncThunk( + "obs/updateContinuityScenes", + async () => { + return await findContinuityScenes(); + }, +); + +// Update continuity scenes every 10 seconds +listenOnStore({ + predicate: (_, oldState, newState) => + newState.obs.connection.connected && !oldState.obs.connection.connected, + effect: async (_, api) => { + logger.info("Connected to OBS, starting continuity scene updates loop"); + api.cancelActiveListeners(); + for (;;) { + await api.dispatch(updateContinuityScenes()); + await api.delay(10_000); + } + }, +}); + +export const addContinuityItemAsScene = createAsyncThunk( + "obs/addContinuityItemAsScene", + async ( + payload: { + continuityItemID: number; + replaceMode?: "none" | "replace" | "force"; + }, + api, + ) => { + const state = api.getState() as AppState; + const show = state.selectedShow.show; + invariant(show, "No show selected"); + const item = show.continuityItems.find( + (x) => x.id === payload.continuityItemID, + ); + invariant(item, "Continuity item not found"); + invariant(item.media, "Continuity item has no media"); + const info = await serverAPI().media.get.query({ id: item.media.id }); + const result = await addOrReplaceMediaAsScene( + info, + payload.replaceMode ?? "none", + show, + state.localMedia.media, + ); + if (result.done) { + return { + ...result, + newScenes: await findContinuityScenes(), + }; + } + return { + ...result, + newScenes: null, + }; + }, +); diff --git a/desktop/src/main/ontime/ipc.ts b/desktop/src/main/ontime/ipc.ts deleted file mode 100644 index d25c16099..000000000 --- a/desktop/src/main/ontime/ipc.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from "zod"; -import { proc, r } from "../base/ipcRouter"; -import { - getOntimeSettings, - ontimeSettingsSchema, - saveOntimeSettings, -} from "../base/settings"; -import { - createOntimeConnection, - getOntimeInstance, - isOntimeConnected, -} from "./ontime"; -import { selectedShow } from "../base/selectedShow"; -import invariant from "../../common/invariant"; -import { showToOntimeEvents } from "./ontimeHelpers"; -import { getLogger } from "../base/logging"; - -const logger = getLogger("ontime/ipc"); - -export const ontimeRouter = r({ - getSettings: proc.output(ontimeSettingsSchema.nullable()).query(async () => { - return getOntimeSettings(); - }), - getConnectionStatus: proc - .output(z.object({ host: z.string() }).nullable()) - .query(async () => { - if (!isOntimeConnected()) { - return null; - } - return { host: getOntimeInstance().host }; - }), - connect: proc - .input(ontimeSettingsSchema) - .output(z.boolean()) - .mutation(async ({ input }) => { - await createOntimeConnection(input.host); - await saveOntimeSettings(input); - return true; - }), - pushEvents: proc - .input( - z.object({ - rundownId: z.number().optional(), - replacementMode: z.enum(["force"]).optional(), - }), - ) - .output( - z.object({ - done: z.boolean(), - }), - ) - .mutation(async ({ input }) => { - const show = selectedShow.value; - invariant(show, "No show selected"); - const events = showToOntimeEvents(show, input.rundownId); - logger.debug("Ready for Ontime push"); - logger.debug(events); - - const current = await getOntimeInstance().getEvents(); - if (input.replacementMode === "force" || current.length === 0) { - const ontime = await getOntimeInstance(); - await ontime.deleteAllEvents(); - // Not in a Promise.all to ensure they're done in order - // NB: A new event is added to the *top* of the rundown in Ontime, so we need to add them in reverse order - for (const event of events.reverse()) { - await ontime.createEvent(event); - } - return { done: true }; - } - - if (current.length !== events.length) { - return { done: false }; - } - for (let i = 0; i < current.length; i++) { - if (events[i] && current[i].title !== events[i].title) { - return { done: false }; - } - } - return { done: true }; - }), -}); diff --git a/desktop/src/main/ontime/ontime.ts b/desktop/src/main/ontime/ontime.ts index c5ec72bff..1484c9dad 100644 --- a/desktop/src/main/ontime/ontime.ts +++ b/desktop/src/main/ontime/ontime.ts @@ -1,9 +1,5 @@ import got, { type Got } from "got"; import invariant from "../../common/invariant"; -import { getOntimeSettings } from "../base/settings"; -import { getLogger } from "../base/logging"; - -const logger = getLogger("ontime"); interface OntimeInfo { networkInterfaces: unknown[]; @@ -128,22 +124,7 @@ export function getOntimeInstance() { } export async function createOntimeConnection(host: string) { - ontimeInstance = await OntimeClient.connect(host); -} - -export async function tryCreateOntimeConnection() { - const settings = await getOntimeSettings(); - if (!settings) { - return; - } - try { - await createOntimeConnection(settings.host); - logger.info("Successfully connected to Ontime"); - } catch (e) { - logger.warn( - "Could not connect to Ontime: " + - (e instanceof Error ? e.message : String(e)), - e, - ); - } + // OntimeClient.connect handles errors + const instance = await OntimeClient.connect(host); + ontimeInstance = instance; } diff --git a/desktop/src/main/ontime/state.ts b/desktop/src/main/ontime/state.ts new file mode 100644 index 000000000..73b350774 --- /dev/null +++ b/desktop/src/main/ontime/state.ts @@ -0,0 +1,96 @@ +import { createAsyncThunk, isAnyOf } from "@reduxjs/toolkit"; +import { createAppSlice } from "../base/reduxHelpers"; +import { createOntimeConnection, getOntimeInstance } from "./ontime"; +import { AppState } from "../store"; +import { getLogger } from "../base/logging"; +import invariant from "../../common/invariant"; +import { showToOntimeEvents } from "./ontimeHelpers"; + +const logger = getLogger("ontime/state"); + +const ontimeSlice = createAppSlice({ + name: "ontime", + initialState: { + connected: false, + host: "", + connectionError: null as string | null, + }, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(connectToOntime.rejected, (state, action) => { + state.connectionError = String(action.error.message); + }); + builder.addMatcher( + isAnyOf(connectToOntime.fulfilled, tryConnectToOntime.fulfilled), + (state, action) => { + if (action.payload) { + state.connected = true; + state.host = action.payload.host; + } + }, + ); + }, +}); + +export const ontimeReducer = ontimeSlice.reducer; + +export const connectToOntime = createAsyncThunk( + "ontime/connect", + async (payload: { serverURL: string }) => { + await createOntimeConnection(payload.serverURL); + return { host: payload.serverURL }; + }, +); + +export const tryConnectToOntime = createAsyncThunk( + "ontime/tryConnect", + async (_, api) => { + const state = api.getState() as AppState; + const settings = state.settings.ontime; + if (!settings.host) { + logger.info("No saved Ontime credentials, skipping connection attempt"); + return; + } + try { + await createOntimeConnection(settings.host); + } catch (e) { + logger.error(`Failed to connect to Ontime using saved credentials: ${e}`); + } + return { host: settings.host }; + }, +); + +export const pushEvents = createAsyncThunk( + "ontime/pushEvents", + async (payload: { rundownID?: number; replacementMode?: "force" }, api) => { + const state = api.getState() as AppState; + const selectedShow = state.selectedShow.show; + invariant(selectedShow, "No show selected"); + const events = showToOntimeEvents(selectedShow, payload.rundownID); + logger.debug("Ready for Ontime push"); + logger.trace(events); + + const ontime = getOntimeInstance(); + const current = await ontime.getEvents(); + if (payload.replacementMode === "force" || current.length === 0) { + await ontime.deleteAllEvents(); + // Not in a Promise.all to ensure they're done in order + // NB: A new event is added to the *top* of the rundown in Ontime, so we need to add them in reverse order + for (const event of events.reverse()) { + await ontime.createEvent(event); + } + return { done: true }; + } + + if (current.length !== events.length) { + return { done: false }; + } + for (let i = 0; i < current.length; i++) { + if (events[i] && current[i].title !== events[i].title) { + return { done: false }; + } + } + // Nothing to do + return { done: true }; + }, +); diff --git a/desktop/src/main/preflight.ts b/desktop/src/main/preflight.ts new file mode 100644 index 000000000..33e2a8a8e --- /dev/null +++ b/desktop/src/main/preflight.ts @@ -0,0 +1,71 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { AppThunk } from "./store"; +import { initialiseSettings } from "./base/settings"; +import { localMediaActions } from "./media/state"; +import { WritableDraft } from "immer"; +import { tryConnectToServer } from "./base/serverConnectionState"; +import { tryConnectToOBS as tryConnectToOBS } from "./obs/state"; +import { tryConnectToOntime } from "./ontime/state"; +import { tryConnectToVMix } from "./vmix/state"; + +const PREFLIGHTS = [ + { name: "Settings", thunk: initialiseSettings, first: true }, + { name: "Local media", thunk: localMediaActions.initialise }, + { name: "Server connection", thunk: tryConnectToServer }, + { name: "OBS connection", thunk: tryConnectToOBS, noDelay: true }, + { name: "Ontime connection", thunk: tryConnectToOntime, noDelay: true }, + { name: "vMix Connection", thunk: tryConnectToVMix, noDelay: true }, +]; + +export interface PreflightTask { + name: string; + status: "pending" | "success" | "error"; + error?: string; +} + +const preflightSlice = createSlice({ + name: "preflight", + initialState: { + tasks: [] as PreflightTask[], + done: false, + }, + reducers: {}, + extraReducers: (builder) => { + const NEEDED = PREFLIGHTS.filter((x) => !x.noDelay).length; + for (const { name, thunk } of PREFLIGHTS) { + builder.addCase(thunk.pending, (state) => { + state.tasks.push({ name, status: "pending" }); + }); + builder.addCase(thunk.fulfilled, (state) => { + const task = state.tasks.find( + (t) => t.name === name, + ) as WritableDraft; + task.status = "success"; + const done = state.tasks.filter((t) => t.status === "success").length; + if (done === NEEDED) { + state.done = true; + } + }); + builder.addCase(thunk.rejected, (state, action) => { + const task = state.tasks.find( + (t) => t.name === name, + ) as WritableDraft; + task.status = "error"; + task.error = action.error.message ?? "Unknown error"; + }); + } + }, +}); + +export const preflightReducer = preflightSlice.reducer; + +export const doPreflight: () => AppThunk = () => async (dispatch) => { + for (const task of PREFLIGHTS.filter((x) => x.first)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await dispatch(task.thunk() as any); + } + for (const task of PREFLIGHTS.filter((x) => !x.first)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dispatch(task.thunk() as any); + } +}; diff --git a/desktop/src/main/store.ts b/desktop/src/main/store.ts new file mode 100644 index 000000000..69d243ef0 --- /dev/null +++ b/desktop/src/main/store.ts @@ -0,0 +1,152 @@ +import { + Action, + configureStore, + Middleware, + ThunkAction, +} from "@reduxjs/toolkit"; +import { combineReducers } from "redux"; +import { devToolsEnhancer as remoteReduxDevToolsEnhancer } from "@redux-devtools/remote"; +import { setSetting, settingsReducer } from "./base/settings"; +import { listener } from "./storeListener"; +import { localMediaActions, localMediaReducer } from "./media/state"; +import { + _enterReducer, + _exitReducer, + changeSelectedShow, + selectedShowReducer, +} from "./base/selectedShow"; +import { preflightReducer } from "./preflight"; +import { + connectToServer, + serverConnectionReducer, +} from "./base/serverConnectionState"; +import { getLogger } from "./base/logging"; +import { inspect } from "util"; +import { serverDataSlice } from "./base/serverDataState"; +import { + addContinuityItemAsScene, + callArbitrary, + connectToOBS, + obsSlice, +} from "./obs/state"; +import { + integrationsReducer, + overrideSupportedIntegrations, +} from "./base/integrations"; +import { connectToOntime, ontimeReducer, pushEvents } from "./ontime/state"; +import { + connectToVMix, + loadAllVTs, + loadAssets, + loadSingleVT, + switchActiveRundown, + vmixReducer, +} from "./vmix/state"; +import { dismissGlobalError, globalErrorReducer } from "./globalError"; +import { cloneDeep, merge } from "lodash"; + +const logger = getLogger("store"); + +const loggerMiddleware: Middleware = (_store) => (next) => (action) => { + if (typeof action !== "object" || action === null) { + return next(action); + } + logger.info(`action: ${(action as Action).type}`); + logger.debug(inspect(action)); + return next(action); +}; + +const topReducer = combineReducers({ + selectedShow: selectedShowReducer, // but see below, it's called slightly differently + globalError: globalErrorReducer, + settings: settingsReducer, + localMedia: localMediaReducer, + preflight: preflightReducer, + serverConnection: serverConnectionReducer, + serverData: serverDataSlice.reducer, + obs: obsSlice.reducer, + integrations: integrationsReducer, + ontime: ontimeReducer, + vmix: vmixReducer, +}); + +export interface AppState extends ReturnType { + selectedShow: ReturnType; +} + +export const store = configureStore({ + reducer: (state: AppState | undefined, action) => { + // Allow resetting the state in tests + if (action.type === "@@RESET") { + state = undefined; + } + if (action.type === "@@PRELOAD") { + if (!state) { + state = topReducer(state, { type: "@@INIT" }); + } else { + state = cloneDeep(state); + } + merge(state, action.payload); + } + // Since nearly every other bit of the application depends on the selected show, + // we have a shortcut to allow all the other reducers to access it without embedding + // it in their state. In effect, we temporarily set the selected show as a global variable, + // expose it to reducers through the getSelectedShow function, and then immediately unset it. + // + // This seems like a side effect and thus forbidden in Redux, but it's actually + // valid, since it's only used within the reducer function itself. + // This is a way to apply the "reducer composition" pattern within the constraints + // of Redux Toolkit. The "clean" Redux way would be for all the other reducers to + // take the current show state as a third argument, but Redux Toolkit doesn't support + // this and we don't want to re-implement it. So we use this global as a pseudo-argument. + // + // Note that, if any other slices want to react to changes in the selected show, as opposed + // to merely reading it while they handle an action originating from their own slice, they + // will need to include showDataChangeMatcher as a reducer case as normal. + const selectedShowState = selectedShowReducer(state?.selectedShow, action); + _enterReducer(selectedShowState.show); + // This calls selectedShowReducer again, but that's okay because it's being called with + // the same state and action. + const newState = topReducer(state, action); + _exitReducer(); + return newState; + }, + middleware: (def) => + def({ + serializableCheck: false, // we have Dates in our state + }).concat(listener.middleware, loggerMiddleware), + + enhancers: (def) => + import.meta.env.BADGER_ENABLE_REDUX_DEVTOOLS !== "true" + ? def() + : def().concat( + remoteReduxDevToolsEnhancer({ hostname: "localhost", port: 5175 }), + ), +}); + +export type AppStore = typeof store; +export type AppDispatch = typeof store.dispatch; +export type AppThunk = ThunkAction; + +export const exposedActionCreators = { + dismissGlobalError, + connectToServer, + changeSelectedShow, + queueMediaDownload: localMediaActions.queueMediaDownload, + downloadAllMediaForSelectedShow: + localMediaActions.downloadAllMediaForSelectedShow, + obsConnect: connectToOBS, + addContinuityItemAsScene, + connectToOntime, + pushEvents, + connectToVMix, + switchActiveRundown, + loadAllVTs, + loadSingleVT, + loadAssets, + overrideSupportedIntegrations, + setSetting, + obsCallArbitrary: callArbitrary, + deleteOldMedia: localMediaActions.deleteOldMedia, +}; +export type ExposedActionCreators = typeof exposedActionCreators; diff --git a/desktop/src/main/storeIpc.ts b/desktop/src/main/storeIpc.ts new file mode 100644 index 000000000..5fb31e8e9 --- /dev/null +++ b/desktop/src/main/storeIpc.ts @@ -0,0 +1,31 @@ +import invariant from "../common/invariant"; +import { ipcMain } from "electron"; +import { ActionCreatorsMapObject } from "redux"; +import { AppStore, ExposedActionCreators } from "./store"; +import { getLogger } from "./base/logging"; + +const logger = getLogger("storeIpc"); + +export function setupStoreIPC( + store: AppStore, + exposedActionCreators: ExposedActionCreators, +) { + ipcMain.on("dispatch", (_event, action) => { + store.dispatch(action); + }); + + ipcMain.handle("getState", () => store.getState()); + + ipcMain.handle("dispatch", (_event, actionType, ...args) => { + invariant( + actionType in exposedActionCreators, + "Tried to dispatch non-exposed action " + actionType, + ); + logger.info(`Dispatching action ${actionType}`); + const creator = (exposedActionCreators as ActionCreatorsMapObject)[ + actionType + ]; + const result = store.dispatch(creator(...args)); + return result; + }); +} diff --git a/desktop/src/main/storeListener.ts b/desktop/src/main/storeListener.ts new file mode 100644 index 000000000..6cd612eb4 --- /dev/null +++ b/desktop/src/main/storeListener.ts @@ -0,0 +1,10 @@ +import { createListenerMiddleware } from "@reduxjs/toolkit"; +import { AppDispatch, AppState } from "./store"; + +export const listener = createListenerMiddleware(); + +export const listenOnStore = listener.startListening.withTypes< + AppState, + AppDispatch +>(); +export const stopListeningOnStore = listener.stopListening; diff --git a/desktop/src/main/vmix/ipc.ts b/desktop/src/main/vmix/ipc.ts deleted file mode 100644 index 49fb46c8b..000000000 --- a/desktop/src/main/vmix/ipc.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { z } from "zod"; -import { proc, r } from "../base/ipcRouter"; -import { createVMixConnection, getVMixConnection } from "./vmix"; -import { getLogger } from "../base/logging"; -import invariant from "../../common/invariant"; -import { serverAPI } from "../base/serverApiClient"; -import { PartialMediaModel } from "@badger/prisma/utilityTypes"; -import { TRPCError } from "@trpc/server"; -import { - addSingleItemToList, - isListPlaying, - loadAssets, - reconcileList, -} from "./vmixHelpers"; -import { VMIX_NAMES } from "../../common/constants"; -import { getLocalMedia } from "../media/mediaManagement"; - -const logger = getLogger("vmix/ipc"); - -export const vmixRouter = r({ - getConnectionState: proc - .output( - z.object({ - connected: z.boolean(), - host: z.string().optional(), - port: z.number().optional(), - version: z.string().optional(), - edition: z.string().optional(), - }), - ) - .query(async () => { - // TODO[BDGR-136]: don't use the connection for this - const conn = getVMixConnection(); - if (conn === null) { - return { connected: false }; - } - const state = await conn.getFullState(); - logger.debug("VMix state", state); - return { - connected: true, - host: conn.host, - port: conn.port, - version: state.version, - edition: state.edition, - }; - }), - tryConnect: proc - .input( - z.object({ - host: z.string(), - port: z.number(), - }), - ) - .output( - z.object({ - connected: z.boolean(), - version: z.string().optional(), - edition: z.string().optional(), - }), - ) - .mutation(async ({ input }) => { - const conn = await createVMixConnection(input.host, input.port); - const state = await conn.getFullState(); - return { - connected: true, - version: state.version, - edition: state.edition, - }; - }), - getCompleteState: proc.query(() => { - const conn = getVMixConnection(); - invariant(conn, "No vMix connection"); - return conn.getFullState(); - }), - loadRundownVTs: proc - .input( - z.object({ - rundownID: z.number(), - force: z.boolean().default(false), - }), - ) - .mutation(async ({ input }) => { - if (!input.force) { - const isPlaying = await isListPlaying(VMIX_NAMES.VTS_LIST); - if (isPlaying) { - return { - ok: false, - reason: "alreadyPlaying", - }; - } - } - - const rundown = await serverAPI().rundowns.get.query({ - id: input.rundownID, - }); - invariant(rundown, "Rundown not found"); - const media = rundown.items - .sort((a, b) => a.order - b.order) - .map | null>((i) => i.media) - .filter((x) => x && x.state === "Ready"); - const localMedia = getLocalMedia(); - const paths = media.map( - (remote) => - localMedia.find((local) => local.mediaID === remote?.id)?.path, - ); - if (paths.some((x) => !x)) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Not all media is downloaded locally", - }); - } - await reconcileList(VMIX_NAMES.VTS_LIST, paths as string[]); - return { - ok: true, - }; - }), - loadSingleRundownVT: proc - .input( - z.object({ - rundownId: z.number(), - itemId: z.number(), - }), - ) - .mutation(async ({ input }) => { - const rundown = await serverAPI().rundowns.get.query({ - id: input.rundownId, - }); - invariant(rundown, "Rundown not found"); - const item = rundown.items.find((x) => x.id === input.itemId); - invariant(item, "Item not found"); - if (!item.media || item.media.state !== "Ready") { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Media not ready", - }); - } - const localMedia = getLocalMedia(); - const path = localMedia.find((x) => x.mediaID === item.media!.id)?.path; - invariant(path, "Local path not found for media " + item.media!.id); - await addSingleItemToList(VMIX_NAMES.VTS_LIST, path); - }), - loadAssets: proc - .input( - z.union([ - z.object({ - rundownID: z.number(), - assetID: z.number(), - }), - z.object({ - rundownID: z.number(), - category: z.string(), - loadType: z.enum(["direct", "list"]), - }), - ]), - ) - .mutation(async ({ input }) => { - const rundown = await serverAPI().rundowns.get.query({ - id: input.rundownID, - }); - invariant(rundown, "Rundown not found"); - if ("category" in input) { - const assets = rundown.assets.filter( - (x) => x.category === input.category, - ); - await loadAssets(assets, input.loadType, input.category); - } else { - const asset = rundown.assets.find((x) => x.id === input.assetID); - invariant(asset, "Asset not found"); - await loadAssets([asset], "direct", asset.category); - } - }), -}); diff --git a/desktop/src/main/vmix/state.ts b/desktop/src/main/vmix/state.ts new file mode 100644 index 000000000..61667c01f --- /dev/null +++ b/desktop/src/main/vmix/state.ts @@ -0,0 +1,251 @@ +import { createAsyncThunk, isAnyOf, PayloadAction } from "@reduxjs/toolkit"; +import { createAppSlice } from "../base/reduxHelpers"; +import { createVMixConnection, getVMixConnection } from "./vmix"; +import { AppState } from "../store"; +import { getSelectedShow, showDataChangeMatcher } from "../base/selectedShow"; +import invariant from "../../common/invariant"; +import { listenOnStore } from "../storeListener"; +import { + addSingleItemToList, + isListPlaying, + matchMediaToRundown, + reconcileList, + loadAssets as doLoadAssets, +} from "./vmixHelpers"; +import { VMIX_NAMES } from "../../common/constants"; +import { PartialMediaType } from "@badger/prisma/utilityTypes"; +import { getLogger } from "../base/logging"; + +const logger = getLogger("vmix/state"); + +const vmixSlice = createAppSlice({ + name: "vmix", + initialState: { + connection: { + connected: false, + connecting: false, + host: "", + port: 0, + version: "", + edition: "", + error: null as string | null, + }, + activeRundownID: null as number | null, + loadedAssetCategories: {} as Record, + loadedVTs: "none" as "all" | "partial" | "none", + loadedVTIDs: [] as number[], + }, + reducers: { + switchRundown(state, action: PayloadAction<{ rundownID: number }>) { + const show = getSelectedShow(); + invariant(show, "No selected show"); + invariant( + show.rundowns.find((x) => x.id === action.payload.rundownID), + "No such rundown", + ); + state.activeRundownID = action.payload.rundownID; + }, + }, + extraReducers: (builder) => { + builder.addCase(connectToVMix.pending, (state) => { + state.connection.connecting = true; + }); + builder.addCase(connectToVMix.rejected, (state, action) => { + state.connection.error = action.error.message ?? "Unknown error"; + state.connection.connecting = false; + }); + + builder.addCase(updateLoadState.fulfilled, (state, action) => { + if (action.payload) { + state.loadedAssetCategories = action.payload.loadedAssetCategories; + state.loadedVTs = action.payload.loadedVTs; + state.loadedVTIDs = action.payload.loadedVTIDs; + } + }); + + builder.addMatcher( + isAnyOf(connectToVMix.fulfilled, tryConnectToVMix.fulfilled), + (state, action) => { + if (action.payload) { + state.connection.connected = true; + state.connection.connecting = false; + state.connection.version = action.payload.version; + state.connection.edition = action.payload.edition; + } + }, + ); + }, +}); + +export const vmixReducer = vmixSlice.reducer; + +export const switchActiveRundown = vmixSlice.actions.switchRundown; + +export const connectToVMix = createAsyncThunk( + "vmix/connect", + async (payload: { host: string; port: number }) => { + const conn = await createVMixConnection(payload.host, payload.port); + const state = await conn.getFullState(); + return { + version: state.version, + edition: state.edition, + }; + }, +); + +export const tryConnectToVMix = createAsyncThunk( + "vmix/tryConnect", + async () => { + let conn; + try { + conn = await createVMixConnection(); + } catch (e) { + return null; + } + let state; + try { + state = await conn.getFullState(); + } catch (e) { + return null; + } + return { + version: state.version, + edition: state.edition, + }; + }, +); + +export const updateLoadState = createAsyncThunk( + // TODO: Rewrite this as a reducer which takes the current state as an action payload + "vmix/updateLoadState", + async (_, api) => { + // TODO(BDGR-170): For now we assume that VTs are always in a list named "VTs" + // and assets are either in a list named after the category, or + // as loose sources named after the local file name. + // This assumption may cease to hold when BDGR-170 is implemented. + const state = api.getState() as AppState; + const show = state.selectedShow.show; + if (!show) { + logger.warn("No show selected"); + return; + } + const selectedRundown = show.rundowns.find( + (x) => x.id === state.vmix.activeRundownID, + ); + if (!selectedRundown) { + logger.warn("No active rundown"); + return; + } + const vmix = getVMixConnection(); + invariant(vmix, "No vMix connection"); + const fullState = await vmix.getFullState(); + return matchMediaToRundown(selectedRundown, fullState); + }, +); + +listenOnStore({ + matcher: isAnyOf( + vmixSlice.actions.switchRundown, + connectToVMix.fulfilled, + tryConnectToVMix.fulfilled, + ), + effect: (_, api) => { + api.dispatch(updateLoadState()); + }, +}); + +listenOnStore({ + matcher: showDataChangeMatcher, + effect: (_, api) => { + api.dispatch(updateLoadState()); + }, +}); + +export const loadAllVTs = createAsyncThunk( + "vmix/loadAllVTs", + async (payload: { rundownID: number; force?: boolean }, api) => { + const vmix = getVMixConnection(); + invariant(vmix, "No vMix connection"); + if (!payload.force) { + const isPlaying = await isListPlaying(VMIX_NAMES.VTS_LIST); + if (isPlaying) { + return { ok: false, reason: "alreadyPlaying" }; + } + } + const state = api.getState() as AppState; + const show = state.selectedShow.show; + invariant(show, "No selected show"); + const rundown = show.rundowns.find((x) => x.id === payload.rundownID); + invariant(rundown, "No such rundown"); + const media = rundown.items + .sort((a, b) => a.order - b.order) + .map((i) => i.media) + .filter((x) => x && x.state === "Ready"); + const localMedia = state.localMedia.media; + const paths = media.map( + (remote) => + localMedia.find((local) => local.mediaID === remote?.id)?.path, + ); + if (paths.some((x) => !x)) { + throw api.rejectWithValue("Not all media is available locally"); + } + await reconcileList(VMIX_NAMES.VTS_LIST, paths as string[]); + return { + ok: true, + }; + }, +); + +export const loadSingleVT = createAsyncThunk( + "vmix/loadSingleVT", + async (payload: { rundownID: number; itemID: number }, api) => { + const state = api.getState() as AppState; + const show = state.selectedShow.show; + invariant(show, "No show selected"); + const rundown = show.rundowns.find((x) => x.id === payload.rundownID); + invariant(rundown, "Rundown not found"); + const item = rundown.items.find((x) => x.id === payload.itemID); + invariant(item, "Item not found"); + if (!item.media) { + throw new Error("Item has no media"); + } + if (item.media.state !== "Ready") { + throw new Error("Media is not ready"); + } + const localMedia = state.localMedia.media; + const local = localMedia.find((x) => x.mediaID === item.media!.id); + invariant(local, "No local media for asset"); + await addSingleItemToList(VMIX_NAMES.VTS_LIST, local.path); + }, +); + +export type LoadAssetsArgs = + | { rundownID: number; category: string; loadType: "direct" | "list" } + | { rundownID: number; assetID: number }; +export const loadAssets = createAsyncThunk( + "vmix/loadAssets", + async (payload: LoadAssetsArgs, api) => { + const state = api.getState() as AppState; + const show = state.selectedShow.show; + invariant(show, "No show selected"); + const rundown = show.rundowns.find((x) => x.id === payload.rundownID); + invariant(rundown, "Rundown not found"); + const localMedia = state.localMedia.media; + + if ("category" in payload) { + const assets = rundown.assets.filter( + (x) => x.category === payload.category, + ); + await doLoadAssets( + assets, + payload.loadType, + payload.category, + localMedia, + ); + } else { + const asset = rundown.assets.find((x) => x.id === payload.assetID); + invariant(asset, "Asset not found"); + await doLoadAssets([asset], "direct", asset.category, localMedia); + } + }, +); diff --git a/desktop/src/main/vmix/vmix.ts b/desktop/src/main/vmix/vmix.ts index 40047be14..8023fed00 100644 --- a/desktop/src/main/vmix/vmix.ts +++ b/desktop/src/main/vmix/vmix.ts @@ -398,24 +398,6 @@ export default class VMixConnection { export let conn: VMixConnection | null; -export async function tryCreateVMixConnection( - host?: string, - port?: number, -): Promise { - if (process.env.__USE_MOCK_VMIX) { - return getMockVMix(); - } - if (!conn) { - try { - conn = await VMixConnection.connect(host, port); - } catch (e) { - logger.warn("Failed to connect to VMix", e); - conn = null; - } - } - return conn; -} - export async function createVMixConnection( host?: string, port?: number, diff --git a/desktop/src/main/vmix/vmixHelpers.ts b/desktop/src/main/vmix/vmixHelpers.ts index b665d375a..6b06db904 100644 --- a/desktop/src/main/vmix/vmixHelpers.ts +++ b/desktop/src/main/vmix/vmixHelpers.ts @@ -1,9 +1,11 @@ +import { CompleteRundownType } from "@badger/prisma/utilityTypes"; import invariant from "../../common/invariant"; import { getLogger } from "../base/logging"; -import { getLocalMedia } from "../media/mediaManagement"; +import { LOCAL_MEDIA_PATH_REGEX } from "../media/constants"; import { getVMixConnection } from "./vmix"; -import { InputType, ListInput, ListItem } from "./vmixTypes"; +import { InputType, ListInput, ListItem, VMixState } from "./vmixTypes"; import type { Asset, Media } from "@badger/prisma/types"; +import { VMIX_NAMES } from "../../common/constants"; const logger = getLogger("vmixHelpers"); @@ -107,8 +109,8 @@ export async function loadAssets( assets: (Asset & { media: Media | null })[], loadType: "direct" | "list", category: string, + localMedia: { mediaID: number; path: string }[], ) { - const localMedia = getLocalMedia(); const vmix = getVMixConnection(); invariant(vmix, "No vMix connection"); const state = await vmix.getFullState(); @@ -157,3 +159,74 @@ export async function loadAssets( } } } + +export function matchMediaToRundown( + selectedRundown: CompleteRundownType, + state: VMixState, +) { + const items = []; + for (const input of state.inputs) { + if (input.type === "VideoList") { + for (const item of (input as ListInput).items) { + const mediaIDMatch = LOCAL_MEDIA_PATH_REGEX.exec(item.source); + if (!mediaIDMatch) { + continue; + } + const mediaID = Number(mediaIDMatch[1]); + items.push({ mediaID, context: input.shortTitle }); + } + } else { + const mediaIDMatch = LOCAL_MEDIA_PATH_REGEX.exec(input.title); + if (!mediaIDMatch) { + continue; + } + const mediaID = Number(mediaIDMatch[1]); + items.push({ mediaID, context: null }); + } + } + + const expectedVTs = selectedRundown.items.filter( + (x) => x.type === "VT" && x.mediaId !== null, + ); + const loadedAssetCategories: Record = {}; + const loadedVTMedia = items.filter((x) => x.context === VMIX_NAMES.VTS_LIST); + let loadedVTs: "all" | "partial" | "none"; + if (loadedVTMedia.length !== expectedVTs.length) { + loadedVTs = "partial"; + } else { + const loadedIDs = new Set(loadedVTMedia.map((x) => x.mediaID)); + if (expectedVTs.every((x) => loadedIDs.has(x.mediaId!))) { + loadedVTs = "all"; + } else { + loadedVTs = "partial"; + } + } + + const expectedAssetsByCategory = new Map>(); + for (const asset of selectedRundown.assets) { + let cur = expectedAssetsByCategory.get(asset.category); + if (!cur) { + cur = new Set(); + expectedAssetsByCategory.set(asset.category, cur); + } + cur.add(asset.id); + } + for (const [cat, expectedMediaIDs] of expectedAssetsByCategory.entries()) { + const loadedAssets = items.filter((x) => x.context === cat); + if (loadedAssets.length !== expectedMediaIDs.size) { + loadedAssetCategories[cat] = "partial"; + } else { + const loadedIDs = new Set(loadedAssets.map((x) => x.mediaID)); + if (Array.from(expectedMediaIDs).every((x) => loadedIDs.has(x))) { + loadedAssetCategories[cat] = "all"; + } else { + loadedAssetCategories[cat] = "partial"; + } + } + } + return { + loadedAssetCategories, + loadedVTs, + loadedVTIDs: loadedVTMedia.map((x) => x.mediaID), + }; +} diff --git a/desktop/src/renderer/.eslintrc.cjs b/desktop/src/renderer/.eslintrc.cjs index 94461c5a5..519e79b6a 100644 --- a/desktop/src/renderer/.eslintrc.cjs +++ b/desktop/src/renderer/.eslintrc.cjs @@ -5,13 +5,17 @@ module.exports = { { paths: [ { - name: "../main/ipcEventBus", - message: - "You can't access the main process's EventBus. Use window.EventBus instead.", - allowTypeImports: false, + name: "electron-settings", }, { - name: "electron-settings", + name: "react-redux", + importNames: ["useSelector"], + message: "Use typed hook `useAppSelector` instead.", + }, + { + name: "react-redux", + importNames: ["useDispatch"], + message: "Use state.ts dispatch export.", }, ], patterns: [ diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index 8cba6969e..2319384ee 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -1,20 +1,24 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ipcClient, ipc } from "./ipc"; import { Suspense, useState } from "react"; -import ConnectAndSelectShowGate from "./ConnectAndSelectShowGate"; -import MainScreen from "./MainScreen"; +import ConnectAndSelectShowGate from "./screens/ConnectAndSelectShowGate"; +import MainScreen from "./screens/MainScreen"; +import { Provider } from "react-redux"; +import { store } from "./store"; +import { PreflightGate } from "./screens/PreflightGate"; export default function App() { const [queryClient] = useState(() => new QueryClient()); return ( - - - - Loading...}> - - - - - + + + + + Loading...}> + + + + + + ); } diff --git a/desktop/src/renderer/ConnectAndSelectShowGate.tsx b/desktop/src/renderer/ConnectAndSelectShowGate.tsx deleted file mode 100644 index 0763c8ef4..000000000 --- a/desktop/src/renderer/ConnectAndSelectShowGate.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { ReactNode, useCallback, useEffect, useState } from "react"; -import { ipc } from "./ipc"; -import Button from "@badger/components/button"; -import { useQueryClient } from "@tanstack/react-query"; -import { getQueryKey } from "@trpc/react-query"; -import invariant from "../common/invariant"; - -function ServerConnectForm() { - const queryClient = useQueryClient(); - const [addrEntry, setAddrEntry] = useState( - import.meta.env.DEV ? "http://localhost:3000" : "https://badger.ystv.co.uk", - ); - const [password, setPassword] = useState(""); - const doConnect = ipc.connectToServer.useMutation(); - const [error, setError] = useState(null); - const connect = useCallback(async () => { - try { - await doConnect.mutateAsync({ endpoint: addrEntry, password }); - await queryClient.invalidateQueries( - getQueryKey(ipc.serverConnectionStatus), - ); - } catch (e) { - setError(String(e)); - } - }, [addrEntry, doConnect, password, queryClient]); - return ( - <> - - - - {error && ( -
{error}
- )} - - ); -} - -export function SelectShowForm(props: { onSelect?: () => void }) { - const queryClient = useQueryClient(); - const listShows = ipc.listUpcomingShows.useQuery(); - const selectShow = ipc.setSelectedShow.useMutation({ - async onSuccess() { - await queryClient.invalidateQueries(getQueryKey(ipc.getSelectedShow)); - props.onSelect?.(); - }, - }); - if (listShows.isLoading) { - return
Please wait, loading shows list...
; - } - if (listShows.error) { - return ( -
-

Error

-
- {listShows.error.message} -
-
{JSON.stringify(listShows.error, null, 2)}
-
- ); - } - invariant(listShows.data, "listShows.data is null"); - return ( -
- {listShows.data.map((show) => ( -
-

{show.name}

- -
- ))} -
- ); -} - -export default function ConnectAndSelectShowGate(props: { - children: ReactNode; -}) { - const queryClient = useQueryClient(); - const connState = ipc.serverConnectionStatus.useQuery(void 0, { - staleTime: 5000, - }); - const selectedShow = ipc.getSelectedShow.useQuery(void 0); - - useEffect(() => { - const handler = async () => { - await queryClient.invalidateQueries(getQueryKey(ipc.getSelectedShow)); - }; - window.IPCEventBus.on("selectedShowChange", handler); - return () => { - window.IPCEventBus.off("selectedShowChange", handler); - }; - }, [queryClient]); - - if (connState.error) { - return ( -
-

Error (checking serverConnectionStatus)

-
- {connState.error.message} -
-
{JSON.stringify(connState.error, null, 2)}
-
- ); - } - if (connState.isLoading || selectedShow.isLoading) { - return
Please wait, getting selected show...
; - } - if ( - connState.data.ok === true && - typeof selectedShow.data === "object" && - selectedShow.data !== null - ) { - return props.children; - } - return ( -
-
-

🦡 Badger

- {connState.data.warnings?.versionSkew && ( -
- Server/Desktop version skew detected! Some features - may not work correctly, if at all. Check the Desktop logs for more - details. -
- )} -
- {connState.data.ok !== true ? ( - - ) : ( - <> -

Select a show

- - - )} -
-
-
- ); -} diff --git a/desktop/src/renderer/actionProxiesRenderer.ts b/desktop/src/renderer/actionProxiesRenderer.ts new file mode 100644 index 000000000..705a521fe --- /dev/null +++ b/desktop/src/renderer/actionProxiesRenderer.ts @@ -0,0 +1,29 @@ +import { ActionCreatorsMapObject } from "redux"; + +/** + * This function creates a proxy object that can be used to dispatch actions + * to the main process store from the renderer process. + */ +export function createRendererActionCreatorsProxy< + T extends ActionCreatorsMapObject, +>() { + return new Proxy( + {}, + { + get(target, prop, receiver) { + if (typeof prop === "string") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (...args: any[]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return window.MainStoreAPI._dispatch(prop as any, ...args); + }; + } + return Reflect.get(target, prop, receiver); + }, + }, + // SHENANIGANS ARE AFOOT! This is what lets us do `dispatch.yourActionType` as if it were a function call. + ) as { + // This mapped type lets us get the type you get when dispatching the action creator. + [K in keyof T]: (...args: Parameters) => ReturnType>; + }; +} diff --git a/desktop/src/renderer/globals.d.ts b/desktop/src/renderer/globals.d.ts new file mode 100644 index 000000000..0830783db --- /dev/null +++ b/desktop/src/renderer/globals.d.ts @@ -0,0 +1,17 @@ +import { Dispatch } from "redux"; +import type { AppState } from "../main/store"; + +interface MainStoreAPIType { + /** @deprecated Not actually deprecated, but you probably want to use the `dispatch` proxy object from `state.ts` instead. */ + _dispatch: Dispatch; + onStateChange: ( + callback: (actionType: string, newState: AppState) => void, + ) => void; + getState: () => Promise; +} + +declare global { + interface Window { + MainStoreAPI: MainStoreAPIType; + } +} diff --git a/desktop/src/renderer/index.tsx b/desktop/src/renderer/index.tsx index b88ed7bee..cda73ee23 100644 --- a/desktop/src/renderer/index.tsx +++ b/desktop/src/renderer/index.tsx @@ -27,6 +27,7 @@ */ import * as Sentry from "@sentry/electron/renderer"; import { init as reactInit } from "@sentry/react"; +import "./init.dev"; import * as ReactDOM from "react-dom/client"; import "./index.css"; import App from "./App"; diff --git a/desktop/src/renderer/init.dev.ts b/desktop/src/renderer/init.dev.ts new file mode 100644 index 000000000..c7aef9f8e --- /dev/null +++ b/desktop/src/renderer/init.dev.ts @@ -0,0 +1,32 @@ +import { Dispatch } from "@reduxjs/toolkit"; +import isElectron from "is-electron"; + +// Initialises MainStoreAPI when we're not running in Electron. +if (import.meta.env.DEV && !isElectron()) { + window.MainStoreAPI = { + _dispatch: (async (actionType, ...args) => { + const result = await fetch("/_dev/dispatch/" + actionType, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(args), + }); + return result.json(); + }) as Dispatch, + getState: async () => { + const result = await fetch("/_dev/getState"); + return result.json(); + }, + onStateChange: (callback) => { + const ws = new WebSocket(`ws://${location.host}/_dev`); + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + callback("@@dev/" + data.type, data.state); + }; + ws.onclose = () => { + window.location.reload(); + }; + }, + }; +} diff --git a/desktop/src/renderer/ipc.ts b/desktop/src/renderer/ipc.ts deleted file mode 100644 index b182bc961..000000000 --- a/desktop/src/renderer/ipc.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { CreateTRPCClientOptions, createTRPCProxyClient } from "@trpc/client"; -import { ipcLink } from "electron-trpc/renderer"; -import { createTRPCReact } from "@trpc/react-query"; -import type { AppRouter } from "../main/ipcApi"; -import { Events } from "../common/ipcEvents"; -import { QueryKey, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; -import logging from "loglevel"; -import { observable } from "@trpc/server/observable"; - -const logger = logging.getLogger("serverIPC"); - -export const ipc = createTRPCReact(); - -const clientConfig: CreateTRPCClientOptions = { - links: [ - (_) => - ({ op, next }) => { - // We need to be exceedingly careful here to not cause an infinite loop - if (op.path === "log") { - return next(op); - } - return observable((observer) => { - ipcProxy.log.mutate({ - level: "trace", - message: `<-- ${op.type} ${op.path}`, - }); - const unsubscribe = next(op).subscribe({ - next: (res) => { - ipcProxy.log.mutate({ - level: "trace", - message: `--> ${op.type} ${op.path} data ${JSON.stringify( - res, - )}`, - }); - observer.next(res); - }, - error: (err) => { - ipcProxy.log.mutate({ - level: "error", - message: `--> ${op.type} ${op.path} ${err}`, - }); - observer.error(err); - }, - complete: () => { - observer.complete(); - }, - }); - return unsubscribe; - }); - }, - ipcLink(), - ], -}; - -const ipcProxy = createTRPCProxyClient(clientConfig); - -export const ipcClient = ipc.createClient(clientConfig); - -const oldFactory = logging.methodFactory; -logging.methodFactory = function (levelName, level, logger) { - return function (message) { - oldFactory(levelName, level, logger)(message); - ipcProxy.log.mutate({ - level: levelName, - logger: typeof logger === "symbol" ? String(logger) : logger, - message, - }); - }; -}; - -const { log, info, warn, error } = console; -window.console.log = (...args: unknown[]) => { - log(...args); - ipcProxy.log.mutate({ level: "debug", message: args.join(" ") }); -}; -window.console.info = (...args: unknown[]) => { - info(...args); - ipcProxy.log.mutate({ level: "info", message: args.join(" ") }); -}; -window.console.warn = (...args: unknown[]) => { - warn(...args); - ipcProxy.log.mutate({ level: "warn", message: args.join(" ") }); -}; -window.console.error = (...args: unknown[]) => { - error(...args); - ipcProxy.log.mutate({ level: "error", message: args.join(" ") }); -}; - -// eslint-disable-next-line no-console -console.info("Renderer IPC logging initialised."); - -export function useInvalidateQueryOnIPCEvent( - query: QueryKey, - event: keyof Events, -) { - const qc = useQueryClient(); - useEffect(() => { - const handler = () => { - logger.debug(`Invalidating query ${query} due to IPC event ${event}`); - qc.invalidateQueries(query); - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - window.IPCEventBus.on(event as any, handler); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return () => window.IPCEventBus.off(event as any, handler); - }, [query, event, qc]); -} diff --git a/desktop/src/renderer/screens/ConnectAndSelectShowGate.tsx b/desktop/src/renderer/screens/ConnectAndSelectShowGate.tsx new file mode 100644 index 000000000..59b3dee49 --- /dev/null +++ b/desktop/src/renderer/screens/ConnectAndSelectShowGate.tsx @@ -0,0 +1,131 @@ +import { ReactNode, useState } from "react"; +import Button from "@badger/components/button"; +import { dispatch, useAppSelector } from "../store"; +import invariant from "../../common/invariant"; + +function ServerConnectForm() { + const [addrEntry, setAddrEntry] = useState( + import.meta.env.DEV ? "http://localhost:3000" : "https://badger.ystv.co.uk", + ); + const [password, setPassword] = useState(""); + const state = useAppSelector((state) => state.serverConnection.state); + const error = useAppSelector((state) => state.serverConnection.error); + + function connect() { + dispatch.connectToServer({ + host: addrEntry, + password, + }); + } + + return ( + <> + + + + {error && ( +
{error}
+ )} + + ); +} + +export function SelectShowForm(props: { onSelect?: () => void }) { + const { upcomingShows, upcomingShowsError, upcomingShowsLoading } = + useAppSelector((state) => state.serverData); + const pending = useAppSelector((state) => state.selectedShow.isLoading); + if (upcomingShowsLoading) { + return
Please wait, loading shows list...
; + } + if (upcomingShowsError) { + return ( +
+

Error

+
+ {upcomingShowsError} +
+
+ ); + } + invariant(upcomingShows, "upcomingShows is null"); + return ( +
+ {upcomingShows.map((show) => ( +
+

{show.name}

+ +
+ ))} +
+ ); + return null; +} + +export default function ConnectAndSelectShowGate(props: { + children: ReactNode; +}) { + const connState = useAppSelector((state) => state.serverConnection); + const selectedShow = useAppSelector( + (state) => state.selectedShow.show !== null, + ); + + if (connState.state === "connected" && selectedShow) { + return props.children; + } + return ( +
+
+

🦡 Badger

+ {connState.versionSkew && ( +
+ Server/Desktop version skew detected! Some features + may not work correctly, if at all. Check the Desktop logs for more + details. +
+ )} +
+ {(connState.state === "connected") !== true ? ( + + ) : ( + <> +

Select a show

+ + + )} +
+
+
+ ); +} diff --git a/desktop/src/renderer/screens/DevToolsSettings.tsx b/desktop/src/renderer/screens/DevToolsSettings.tsx new file mode 100644 index 000000000..70a7d06eb --- /dev/null +++ b/desktop/src/renderer/screens/DevToolsSettings.tsx @@ -0,0 +1,88 @@ +import { Switch } from "@badger/components/switch"; +import { Label } from "@badger/components/label"; +import Button from "@badger/components/button"; +import { dispatch, useAppSelector } from "../store"; +import isElectron from "is-electron"; +import logging from "loglevel"; + +async function trySendRenderer(fn: string) { + if (isElectron()) { + const { ipcRenderer } = await import( + /* @vite-ignore */ `${"electron/renderer"}` + ); + ipcRenderer.send(fn); + } else { + logging.warn(`Tried to send ${fn} but not in electron`); + } +} + +export function DevToolsSettings() { + const enabled = useAppSelector((state) => state.settings.devtools.enabled); + const integrations = useAppSelector((state) => state.integrations.supported); + return ( +
+

Developer Tools

+

+ Do not enable unless you know what you are doing, these open you up to + (theoretical) security vulnerabilities. +

+
+ + dispatch.setSetting("devtools", "enabled", v) + } + /> + +
+ {enabled && ( +
+

Enabled integrations

+ {(["obs", "vmix", "ontime"] as const).map((int) => ( +
+ { + const newIntegrations = [...integrations]; + if (v) { + newIntegrations.push(int); + } else { + newIntegrations.splice(newIntegrations.indexOf(int), 1); + } + dispatch.overrideSupportedIntegrations(newIntegrations); + }} + /> + +
+ ))} + + + +
+ )} +
+ ); +} diff --git a/desktop/src/renderer/MainScreen.tsx b/desktop/src/renderer/screens/MainScreen.tsx similarity index 78% rename from desktop/src/renderer/MainScreen.tsx rename to desktop/src/renderer/screens/MainScreen.tsx index e72b1735c..a2df756a2 100644 --- a/desktop/src/renderer/MainScreen.tsx +++ b/desktop/src/renderer/screens/MainScreen.tsx @@ -1,5 +1,4 @@ -import { ipc, useInvalidateQueryOnIPCEvent } from "./ipc"; -import invariant from "../common/invariant"; +import invariant from "../../common/invariant"; import { Dialog, DialogContent, @@ -18,42 +17,31 @@ import { DropdownMenuTrigger, } from "@badger/components/dropdown-menu"; import { Button } from "@badger/components/button"; -import { - IoAlertSharp, - IoCaretDownOutline, - IoCheckmarkSharp, - IoCog, - IoDownloadSharp, -} from "react-icons/io5"; -import { Suspense, useMemo, useState } from "react"; -import OBSScreen from "./screens/OBS"; -import VMixScreen from "./screens/vMix"; -import { Settings } from "./screens/Settings"; -import { SelectShowForm } from "./ConnectAndSelectShowGate"; +import { IoCaretDownOutline, IoCog, IoDownloadSharp } from "react-icons/io5"; +import { Suspense, useState } from "react"; +import OBSScreen from "./OBS"; +import VMixScreen from "./vMix"; +import { Settings } from "./Settings"; import { Table, TableBody, TableCell, TableRow, } from "@badger/components/table"; -import { OntimePush } from "./screens/Ontime"; -import { getQueryKey } from "@trpc/react-query"; +import { dispatch, useAppSelector } from "../store"; +import { OntimePush } from "./Ontime"; +import { SelectShowForm } from "./ConnectAndSelectShowGate"; +import { Alert } from "@badger/components/alert"; function DownloadTrackerPopup() { - const downloadStatus = ipc.media.getDownloadStatus.useQuery(void 0, { - refetchInterval: 1000, - }); - useInvalidateQueryOnIPCEvent( - getQueryKey(ipc.media.getDownloadStatus), - "downloadStatusChange", + const downloads = useAppSelector((state) => + (state.localMedia.currentDownload + ? [state.localMedia.currentDownload] + : [] + ).concat(state.localMedia.downloadQueue), ); - const downloads = useMemo( - () => downloadStatus.data?.filter((x) => x.status !== "done"), - [downloadStatus.data], - ); - - if (!downloads?.length) { + if (!downloads.length) { return null; } @@ -86,12 +74,32 @@ function DownloadTrackerPopup() { ); } +function GlobalAlerts() { + const alerts = useAppSelector((state) => state.globalError.errors); + return ( +
+ {alerts.map((alert) => ( + + {alert.message} + + + ))} +
+ ); +} + export default function MainScreen() { - const { data: show } = ipc.getSelectedShow.useQuery(); + const show = useAppSelector((state) => state.selectedShow.show); invariant(show, "no selected show"); // this is safe because MainScreen is rendered inside a ConnectAndSelectShowGate - const [integrations] = ipc.supportedIntegrations.useSuspenseQuery(); - - const downloadAll = ipc.media.downloadAllMediaForSelectedShow.useMutation(); + const integrations = useAppSelector((state) => state.integrations.supported); const [isChangeShowOpen, setIsChangeShowOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -105,13 +113,13 @@ export default function MainScreen() { : show.rundowns.find((rd) => rd.id === selectedRundown)?.name; invariant(selectedName, "selected non-existent rundown"); - const ontimeState = ipc.ontime.getConnectionStatus.useQuery(); + const ontimeState = useAppSelector((state) => state.ontime); const [ontimePushOpen, setOntimePushOpen] = useState(false); return (
+ -
+
{selectedRundown === "continuity" ? ( ) : ( diff --git a/desktop/src/renderer/screens/MediaSettings.tsx b/desktop/src/renderer/screens/MediaSettings.tsx index 218d61323..49a850bc2 100644 --- a/desktop/src/renderer/screens/MediaSettings.tsx +++ b/desktop/src/renderer/screens/MediaSettings.tsx @@ -1,5 +1,4 @@ import Button from "@badger/components/button"; -import { ipc } from "../ipc"; import { Table, TableBody, @@ -17,8 +16,8 @@ import { } from "@badger/components/dropdown-menu"; import { IoChevronDownSharp } from "react-icons/io5"; import { Badge } from "@badger/components/badge"; -import { useQueryClient } from "@tanstack/react-query"; -import { getQueryKey } from "@trpc/react-query"; +import { dispatch, useAppSelector } from "../store"; +// import { getQueryKey } from "@trpc/react-query"; function humaniseSize(bytes: number) { if (bytes < 1024) { @@ -34,19 +33,12 @@ function humaniseSize(bytes: number) { } export function MediaSettings() { - const queryClient = useQueryClient(); - const [data] = ipc.media.getLocalMedia.useSuspenseQuery({ - includeSize: true, - }); - const [mediaPath] = ipc.media.getPath.useSuspenseQuery(); - const [currentShow] = ipc.getSelectedShow.useSuspenseQuery(); - const open = ipc.media.openPath.useMutation(); - const deleteOldMedia = ipc.media.deleteOldMedia.useMutation({ - async onSuccess() { - await queryClient.invalidateQueries(getQueryKey(ipc.media.getLocalMedia)); - }, - }); - const totalSpace = data.map((x) => x.sizeBytes!).reduce((a, b) => a + b, 0); + const localMedia = useAppSelector((state) => state.localMedia.media); + const mediaPath = useAppSelector((state) => state.settings.media.mediaPath); + const currentShow = useAppSelector((state) => state.selectedShow.show); + const totalSpace = localMedia + .map((x) => x.sizeBytes!) + .reduce((a, b) => a + b, 0); const mediaInShow = useMemo(() => { const ids = new Set(); @@ -64,7 +56,7 @@ export function MediaSettings() { ); return ids; }, [currentShow]); - function isMediaInShow(item: (typeof data)[0]) { + function isMediaInShow(item: (typeof localMedia)[0]) { return mediaInShow.has(item.mediaID); } @@ -73,14 +65,11 @@ export function MediaSettings() {

Media

Location: {mediaPath} -

Total disk usage: {humaniseSize(totalSpace)}

- + - {state.data?.connected && ( + {connected && ( - Successfully connected to OBS version {state.data.version} on{" "} - {state.data.platform} + Successfully connected to OBS version {version} on {platform} )} {error && ( @@ -111,42 +99,35 @@ function AddToOBS({ }: { item: z.infer; }) { - const queryClient = useQueryClient(); - const addToOBS = ipc.obs.addMediaAsScene.useMutation(); - const localMedia = ipc.media.getLocalMedia.useQuery(); - const existing = ipc.obs.listContinuityItemScenes.useQuery(); + const itemMediaID = item.media?.id; + + const existing = useAppSelector((state) => + state.obs.continuityScenes.find((x) => x.continuityItemID === item.id), + ); + const currentItemMedia = useAppSelector((state) => + state.localMedia.media.find((x) => x.mediaID === itemMediaID), + ); + const currentItemDownloadStatus = useAppSelector((state) => + state.localMedia.currentDownload?.mediaID === itemMediaID + ? state.localMedia.currentDownload + : state.localMedia.downloadQueue.find((x) => x.mediaID === itemMediaID), + ); + const [alert, setAlert] = useState(null); - const downloadMedia = ipc.media.downloadMedia.useMutation(); - const downloadStatus = ipc.media.getDownloadStatus.useQuery(void 0, { - refetchInterval: 1000, - }); - useInvalidateQueryOnIPCEvent( - getQueryKey(ipc.media.getLocalMedia), - "localMediaStateChange", - ); - const ourDownloadStatus = useMemo( - () => downloadStatus.data?.find((x) => x.mediaID === item.media?.id), - [downloadStatus.data, item.media?.id], - ); - useEffect(() => { - if (ourDownloadStatus?.status === "done") { - queryClient.invalidateQueries(getQueryKey(ipc.media.getLocalMedia)); - } - }, [ourDownloadStatus?.status, queryClient]); + const doAdd = useCallback( async (replaceMode?: "replace" | "force") => { invariant(item.media, "AddToOBS doAdd callback with no media"); - const result = await addToOBS.mutateAsync({ - id: item.media.id, - replaceMode, - }); + const result = await dispatch + .addContinuityItemAsScene({ + continuityItemID: item.id, + replaceMode, + }) + .unwrap(); if (result.warnings.length === 0 && result.done) { - await queryClient.invalidateQueries( - getQueryKey(ipc.obs.listContinuityItemScenes), - ); return; } setAlert({ @@ -154,8 +135,10 @@ function AddToOBS({ prompt: result.promptReplace ?? "ok", }); }, - [item.media, addToOBS, queryClient], + [item.id, item.media], ); + + // TODO[BDGR-170]: When this is refactored, all this logic should move to the main process. const state = useMemo(() => { if (!item.media) { return "no-media"; @@ -167,49 +150,35 @@ function AddToOBS({ if (item.media.state !== "Ready") { return "media-processing"; } - if (!localMedia.data || !existing.data) { - return "loading"; - } - if (ourDownloadStatus?.status === "downloading") { + if (currentItemDownloadStatus?.status === "downloading") { return "downloading"; } - const alreadyPresent = existing.data.find( - (x) => x.continuityItemID === item.id, - ); - if (alreadyPresent) { + + if (existing) { // check if we need to replace - if (alreadyPresent.sources.length !== 1) { + if (existing.sources.length !== 1) { return "needs-force"; } - const source = alreadyPresent.sources[0]; + const source = existing.sources[0]; if (source.mediaID !== item.media.id) { - if (!localMedia.data.some((x) => x.mediaID === item.media!.id)) { + if (currentItemMedia?.mediaID !== item.media.id) { return "needs-replace-download"; } return "needs-replace"; } return "ok"; } - if (!localMedia.data.some((x) => x.mediaID === item.media!.id)) { + if (!currentItemMedia) { return "needs-download"; } return "needs-add"; - }, [ - existing.data, - item.id, - item.media, - localMedia.data, - ourDownloadStatus?.status, - ]); + }, [currentItemDownloadStatus, currentItemMedia, existing, item.media]); let contents; switch (state) { case "no-media": contents = No media uploaded; break; - case "loading": - contents = Please wait, checking status...; - break; case "archived": contents = Media archived on server; break; @@ -220,7 +189,7 @@ function AddToOBS({ contents = (
@@ -230,9 +199,8 @@ function AddToOBS({ contents = ( + */} - addAll.reset()} > @@ -458,7 +406,7 @@ export default function OBSScreen() { - + */}
); } diff --git a/desktop/src/renderer/screens/OBSDevTools.tsx b/desktop/src/renderer/screens/OBSDevTools.tsx index e43749a46..63ce1babe 100644 --- a/desktop/src/renderer/screens/OBSDevTools.tsx +++ b/desktop/src/renderer/screens/OBSDevTools.tsx @@ -1,12 +1,12 @@ import { useCallback, useState } from "react"; -import { ipc } from "../ipc"; import { Button } from "@badger/components/button"; +import { dispatch, useAppSelector } from "../store"; +import { OBSRequestTypes } from "obs-websocket-js"; export default function OBSDevToolsScreen() { - const connState = ipc.obs.getConnectionState.useQuery(); + const connState = useAppSelector((state) => state.obs.connection); const [req, setReq] = useState(""); const [args, setArgs] = useState("{}"); - const execute = ipc.obs.dev.callArbitrary.useMutation(); const doExecute = useCallback(async () => { let argsJSON; try { @@ -15,8 +15,12 @@ export default function OBSDevToolsScreen() { alert("Invalid args JSON"); return; } - execute.mutate({ req, params: argsJSON }); - }, [req, args, execute]); + dispatch.obsCallArbitrary({ + req: req as keyof OBSRequestTypes, + data: argsJSON, + }); + }, [req, args]); + const callResult = useAppSelector((state) => state.obs.arbitraryCallResult); return (
@@ -29,7 +33,7 @@ export default function OBSDevToolsScreen() { onChange={(e) => setReq(e.target.value)} className="border-2 border-black" > - {connState.data?.availableRequests + {connState.availableRequests ?.sort((a, b) => a.localeCompare(b)) ?.map((r) => (
); } diff --git a/desktop/src/renderer/screens/Ontime.tsx b/desktop/src/renderer/screens/Ontime.tsx index cdcd9d475..1302d7e27 100644 --- a/desktop/src/renderer/screens/Ontime.tsx +++ b/desktop/src/renderer/screens/Ontime.tsx @@ -1,6 +1,3 @@ -import { ipc } from "../ipc"; -import { useQueryClient } from "@tanstack/react-query"; -import { getQueryKey } from "@trpc/react-query"; import { Label } from "@badger/components/label"; import { Input } from "@badger/components/input"; import { Button } from "@badger/components/button"; @@ -16,19 +13,14 @@ import { } from "@badger/components/alert-dialog"; import { Dialog, DialogContent, DialogHeader } from "@badger/components/dialog"; import { CompleteShowType } from "../../common/types"; +import { dispatch, useAppSelector } from "../store"; +import { useRef, useState } from "react"; export function OntimeSettings() { - const queryClient = useQueryClient(); - const [status] = ipc.ontime.getConnectionStatus.useSuspenseQuery(); - const [settings] = ipc.ontime.getSettings.useSuspenseQuery(); - const connect = ipc.ontime.connect.useMutation({ - async onSettled() { - await queryClient.invalidateQueries( - getQueryKey(ipc.ontime.getConnectionStatus), - ); - await queryClient.invalidateQueries(getQueryKey(ipc.ontime.getSettings)); - }, - }); + const { connected, host, connectionError } = useAppSelector( + (state) => state.ontime, + ); + const [isConnecting, setIsConnecting] = useState(false); return (
@@ -37,9 +29,14 @@ export function OntimeSettings() { onSubmit={(e) => { e.preventDefault(); const values = new FormData(e.currentTarget); - connect.mutate({ - host: values.get("host") as string, - }); + setIsConnecting(true); + dispatch + .connectToOntime({ + serverURL: values.get("host") as string, + }) + .finally(() => { + setIsConnecting(false); + }); }} >
@@ -48,19 +45,21 @@ export function OntimeSettings() { id="host" name="host" type="text" - defaultValue={settings?.host} + defaultValue={"http://localhost:4001"} placeholder="http://localhost:4001" />
- - {connect.error && ( - {connect.error.message} - )} - {status !== null && ( + {connectionError && {connectionError}} + {connected && ( - Connected to Ontime at {status.host} + Connected to Ontime at {host} )} @@ -76,11 +75,9 @@ export function OntimePush(props: { dialogOpen: boolean; setDialogOpen: (v: boolean) => unknown; }) { - const ontimePush = ipc.ontime.pushEvents.useMutation({ - onSuccess() { - props.setDialogOpen(false); - }, - }); + const lastPushTarget = useRef(null); + const [needsForce, setNeedsForce] = useState(false); + const [pushError, setPushError] = useState(null); return ( <> @@ -96,12 +93,23 @@ export function OntimePush(props: { if (!rundownId) { return; } - ontimePush.mutate({ - rundownId: - rundownId === "all" - ? undefined - : parseInt(rundownId as string, 10), - }); + lastPushTarget.current = rundownId as string; + dispatch + .pushEvents({ + rundownID: + rundownId === "all" + ? undefined + : parseInt(rundownId as string, 10), + }) + .unwrap() + .then((v) => { + if (!v.done) { + setNeedsForce(true); + } + }) + .catch((e) => { + setPushError(e.message); + }); }} >
- {ontimePush.isError && ( - - Push failed: {ontimePush.error.message} - + {pushError && ( + Push failed: {pushError} )}
- {ontimePush.isSuccess && !ontimePush.data.done && ( - ontimePush.reset()}> + {needsForce && ( + There are already events present in Ontime. Would you like to @@ -146,12 +152,23 @@ export function OntimePush(props: { Cancel - ontimePush.mutate({ - rundownId: ontimePush.variables!.rundownId, - replacementMode: "force", - }) - } + onClick={() => { + dispatch + .pushEvents({ + rundownID: + lastPushTarget.current === "all" + ? undefined + : parseInt(lastPushTarget.current!, 10), + replacementMode: "force", + }) + .unwrap() + .catch((e) => { + setPushError(e.message); + }) + .finally(() => { + setNeedsForce(false); + }); + }} > Replace diff --git a/desktop/src/renderer/screens/PreflightGate.tsx b/desktop/src/renderer/screens/PreflightGate.tsx new file mode 100644 index 000000000..5b4c5569e --- /dev/null +++ b/desktop/src/renderer/screens/PreflightGate.tsx @@ -0,0 +1,50 @@ +import { ReactNode } from "react"; +import type { PreflightTask } from "../../main/preflight"; +import { useAppSelector } from "../store"; + +function PreflightShell(props: { status: string; tasks: PreflightTask[] }) { + return ( +
+
+

🦡 Badger

+
+ {props.status} + {props.tasks.map((task) => ( +
+ {task.status === "success" + ? "✅" + : task.status === "error" + ? "❌" + : "⏳"} +  {task.name} + {task.error &&
{task.error}
} +
+ ))} +
+
+
+ ); +} + +export function PreflightGate(props: { children: ReactNode }) { + const state = useAppSelector((state) => state?.preflight ?? null); + // If we have no state at all, we're still loading + if (!state || Object.keys(state).length === 0) { + return ( + + ); + } + + if (!state.done) { + return ( + + ); + } + return props.children; +} diff --git a/desktop/src/renderer/screens/Settings.tsx b/desktop/src/renderer/screens/Settings.tsx index bf77f2338..9b27aa95b 100644 --- a/desktop/src/renderer/screens/Settings.tsx +++ b/desktop/src/renderer/screens/Settings.tsx @@ -1,10 +1,4 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { ipc } from "../ipc"; -import { getQueryKey } from "@trpc/react-query"; import { OBSSettings } from "./OBS"; -import OBSDevToolsScreen from "./OBSDevTools"; -import { Switch } from "@badger/components/switch"; -import { Label } from "@badger/components/label"; import { Tabs, TabsList, @@ -13,74 +7,24 @@ import { } from "@badger/components/tabs"; import { VMixConnection } from "./vMix"; import { OntimeSettings } from "./Ontime"; -import Button from "@badger/components/button"; import { MediaSettings } from "./MediaSettings"; +import { dispatch, useAppSelector } from "../store"; +import { DevToolsSettings } from "./DevToolsSettings"; +import { Label } from "@badger/components/label"; import { Select, - SelectContent, - SelectItem, SelectTrigger, SelectValue, + SelectContent, + SelectItem, } from "@badger/components/select"; -import { LogLevelNames } from "loglevel"; +import type { LogLevelNames } from "loglevel"; export function Settings() { - const queryClient = useQueryClient(); - const [devToolsState] = ipc.devtools.getSettings.useSuspenseQuery(); - const [integrations] = ipc.supportedIntegrations.useSuspenseQuery(); - const [availableDownloaders] = - ipc.media.getAvailableDownloaders.useSuspenseQuery(); - const [selectedDownloader] = - ipc.media.getSelectedDownloader.useSuspenseQuery(); - const [logLevel] = ipc.getLogLevel.useSuspenseQuery(); + const integrations = useAppSelector((state) => state.integrations.supported); - const doMainError = ipc.devtools.throwException.useMutation(); - const doMainCrash = ipc.devtools.crash.useMutation(); - const doSetIntegrations = ipc.devtools.setEnabledIntegrations.useMutation({ - onSettled() { - queryClient.invalidateQueries(getQueryKey(ipc.supportedIntegrations)); - }, - }); - const doSetDownloader = ipc.media.setSelectedDownloader.useMutation({ - onSettled() { - queryClient.invalidateQueries( - getQueryKey(ipc.media.getSelectedDownloader), - ); - }, - }); - const doSetLogLevel = ipc.setLogLevel.useMutation({ - onSettled() { - queryClient.invalidateQueries(getQueryKey(ipc.getLogLevel)); - }, - }); + const logLevel = useAppSelector((state) => state.settings.logging.level); - const setDevToolsState = ipc.devtools.setSettings.useMutation({ - // https://tanstack.com/query/latest/docs/react/guides/optimistic-updates - async onMutate(newSettings) { - await queryClient.cancelQueries(getQueryKey(ipc.devtools.getSettings)); - const oldSettings = queryClient.getQueryData( - getQueryKey(ipc.devtools.getSettings), - ); - queryClient.setQueryData( - getQueryKey(ipc.devtools.getSettings), - newSettings, - ); - return { oldSettings }; - }, - async onError(_err, _newSettings, context) { - if (context) { - queryClient.setQueryData( - getQueryKey(ipc.devtools.getSettings), - context.oldSettings, - ); - } - }, - async onSettled() { - await queryClient.invalidateQueries( - getQueryKey(ipc.devtools.getSettings), - ); - }, - }); return ( @@ -95,9 +39,9 @@ export function Settings() { )} Media Advanced - {devToolsState.enabled && ( + {/* {devToolsState.enabled && ( OBS Developer Tools - )} + )} */} About {integrations.includes("obs") && ( @@ -120,7 +64,7 @@ export function Settings() {

Downloads

- + {/* + */}

Logging

-

Developer Tools

-

- Do not enable unless you know what you are doing, these open you up to - (theoretical) security vulnerabilities. -

-
- - setDevToolsState.mutate({ enabled: v }) - } - /> - -
- {devToolsState.enabled && ( -
-

Enabled integrations

- {(["obs", "vmix", "ontime"] as const).map((int) => ( -
- { - const newIntegrations = [...integrations]; - if (v) { - newIntegrations.push(int); - } else { - newIntegrations.splice(newIntegrations.indexOf(int), 1); - } - doSetIntegrations.mutate(newIntegrations); - }} - /> - -
- ))} - - - -
- )} +
- {devToolsState.enabled && ( + {/* {devToolsState.enabled && (
- )} + )} */}

Badger

diff --git a/desktop/src/renderer/ShowsList.tsx b/desktop/src/renderer/screens/ShowsList.tsx similarity index 100% rename from desktop/src/renderer/ShowsList.tsx rename to desktop/src/renderer/screens/ShowsList.tsx diff --git a/desktop/src/renderer/screens/vMix.tsx b/desktop/src/renderer/screens/vMix.tsx index b86ef7156..04d4c7262 100644 --- a/desktop/src/renderer/screens/vMix.tsx +++ b/desktop/src/renderer/screens/vMix.tsx @@ -1,15 +1,12 @@ -import { ipc, useInvalidateQueryOnIPCEvent } from "../ipc"; import { Button } from "@badger/components/button"; -import { useEffect, useMemo, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { getQueryKey } from "@trpc/react-query"; +import { useMemo, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; import { CompleteAssetSchema, CompleteRundownItemSchema, CompleteRundownModel, } from "@badger/prisma/utilityTypes"; import { z } from "zod"; -import { ListInput } from "../../main/vmix/vmixTypes"; import invariant from "../../common/invariant"; import { Alert } from "@badger/components/alert"; import { Progress } from "@badger/components/progress"; @@ -36,7 +33,6 @@ import { DropdownMenuTrigger, } from "@badger/components/dropdown-menu"; import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu"; -import { VMIX_NAMES } from "../../common/constants"; import { AlertDialog, AlertDialogAction, @@ -47,17 +43,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@badger/components/alert-dialog"; +import { dispatch, useAppSelector } from "../store"; +import { LoadAssetsArgs } from "../../main/vmix/state"; export function VMixConnection() { - const [state] = ipc.vmix.getConnectionState.useSuspenseQuery(); - const tryConnect = ipc.vmix.tryConnect.useMutation({ - async onSettled() { - await queryClient.invalidateQueries( - getQueryKey(ipc.vmix.getConnectionState), - ); - }, - }); - const queryClient = useQueryClient(); + const state = useAppSelector((state) => state.vmix.connection); return (

@@ -66,9 +56,9 @@ export function VMixConnection() { onSubmit={(e) => { e.preventDefault(); const values = new FormData(e.currentTarget); - tryConnect.mutate({ + dispatch.connectToVMix({ host: values.get("host") as string, - port: parseInt(values.get("port") as string, 10), + port: parseInt(values.get("port") as string), }); }} > @@ -88,10 +78,8 @@ export function VMixConnection() { - {tryConnect.error && ( -
- {tryConnect.error.message} -
+ {state.error && ( +
{state.error}
)} {state.connected && ( @@ -114,52 +102,32 @@ type ItemState = | "loaded"; function RundownVTs(props: { rundown: z.infer }) { - const queryClient = useQueryClient(); - const vmixState = ipc.vmix.getCompleteState.useQuery(); - const downloadState = ipc.media.getDownloadStatus.useQuery(undefined, { - refetchInterval: (data) => - data?.some((x) => x.status !== "done") ? 1_000 : false, - }); - const localMedia = ipc.media.getLocalMedia.useQuery(undefined, { - refetchInterval: () => - downloadState.data?.some((x) => x.status !== "done") ? 1_000 : 10_000, - staleTime: 2_500, - }); - const doLoad = ipc.vmix.loadRundownVTs.useMutation({ - onSuccess() { - queryClient.invalidateQueries(getQueryKey(ipc.vmix.getCompleteState)); - }, - }); - const doLoadSingle = ipc.vmix.loadSingleRundownVT.useMutation({ - onSuccess() { - queryClient.invalidateQueries(getQueryKey(ipc.vmix.getCompleteState)); - }, + const localMedia = useAppSelector((state) => state.localMedia.media); + const downloadState = useAppSelector((state) => + state.localMedia.currentDownload + ? [state.localMedia.currentDownload, ...state.localMedia.downloadQueue] + : state.localMedia.downloadQueue, + ); + const vmixLoaded = useAppSelector((state) => state.vmix.loadedVTIDs); + + const doLoad = useMutation({ + mutationFn: (args: { rundownID: number; force?: boolean }) => + dispatch.loadAllVTs(args).unwrap(), }); - const doDownload = ipc.media.downloadMedia.useMutation({ - onSuccess() { - queryClient.invalidateQueries(getQueryKey(ipc.media.getDownloadStatus)); - }, + const doLoadSingle = useMutation({ + mutationFn: (args: { rundownID: number; itemID: number }) => + dispatch.loadSingleVT(args).unwrap(), }); const [pendingSingleLoadItem, setPendingSingleLoadItem] = useState(null); - - const vtsListState = useMemo(() => { - if (!vmixState.data) { - return null; - } - return ( - (vmixState.data.inputs.find( - (x) => x.shortTitle === VMIX_NAMES.VTS_LIST, - ) as ListInput) ?? null - ); - }, [vmixState.data]); const items: Array< z.infer & { _state: ItemState; _downloadProgress?: number; } > = useMemo(() => { + // TODO: Refactor this into main-side state computed by reducer return props.rundown.items .filter((item) => item.type !== "Segment") .sort((a, b) => a.order - b.order) @@ -183,13 +151,9 @@ function RundownVTs(props: { rundown: z.infer }) { _state: "media-processing", }; } - const local = localMedia.data?.find( - (x) => x.mediaID === item.media!.id, - ); + const local = localMedia.find((x) => x.mediaID === item.media!.id); if (!local) { - const dl = downloadState.data?.find( - (x) => x.mediaID === item.media!.id, - ); + const dl = downloadState.find((x) => x.mediaID === item.media!.id); if (dl) { switch (dl.status) { case "downloading": @@ -224,7 +188,7 @@ function RundownVTs(props: { rundown: z.infer }) { _state: "no-local", }; } - if (vtsListState?.items?.find((x) => x.source === local.path)) { + if (vmixLoaded.find((x) => x === local.mediaID)) { return { ...item, _state: "loaded", @@ -235,12 +199,7 @@ function RundownVTs(props: { rundown: z.infer }) { _state: "ready", }; }); - }, [ - downloadState.data, - localMedia.data, - props.rundown.items, - vtsListState?.items, - ]); + }, [downloadState, localMedia, props.rundown.items, vmixLoaded]); return ( <> @@ -291,10 +250,7 @@ function RundownVTs(props: { rundown: z.infer }) { item.media, "no media for item in download button handler", ); - await doDownload.mutateAsync({ id: item.media.id }); - await queryClient.invalidateQueries( - getQueryKey(ipc.media.getDownloadStatus), - ); + dispatch.queueMediaDownload({ mediaID: item.media.id }); }} > {item._state === "no-local" ? "Download" : "Retry"} @@ -321,7 +277,7 @@ function RundownVTs(props: { rundown: z.infer }) {