AI-assisted project. This codebase was created with Claude (Anthropic), directed and reviewed by a human author — including architecture, implementation, and documentation. Review it accordingly before relying on it in production.
The presentation laptop companion app for presentation-commander-server. A bespoke PDF presentation engine built as an Electron + React + TypeScript desktop app — no PowerPoint or Keynote dependency.
- Bespoke PDF engine — open a PDF, get Now/Next slide previews rendered
locally with pdf.js. Internal links authored into the PDF (a table of
contents, "back to agenda" links exported from PowerPoint/Keynote/Google
Slides) are clickable on the "Now" slide, jumping straight to their target
page — reads link annotations via pdf.js, resolving each destination to a
page number regardless of whether it's named or explicit; PDF-only, since
it's exposed as an optional
getLinksonSlideSourcethat onlypdfSource.tsimplements - Keynote integration (macOS) — drive a real, currently-open Keynote slideshow directly via AppleScript instead of a pre-exported PDF: slide count and presenter notes are pulled on open, navigating in our UI advances the real Keynote window, and advancing Keynote itself (clicker, arrow keys) is polled and reflected back within ~400ms
- PowerPoint integration (macOS and Windows) — same idea as Keynote,
platform-appropriate mechanism: AppleScript on macOS, PowerShell COM
automation (
New-Object -ComObject PowerPoint.Application) on Windows, behind the sameSlideSourceinterface either way. Slide count and presenter notes are pulled on open; navigating in our UI drives the real PowerPoint editing view. On macOS, PowerPoint's AppleScript dictionary has no working bulk slide-image export (save … as PNG/PDFis declared but is a silent no-op in the tested version), so frames are captured one slide at a time viacopy object+ reading the resulting image off the system clipboard. On Windows,Slide.Export(path, "PNG", w, h)genuinely works, so export is a plain bulk loop — no clipboard round-trip needed. Neither platform drives PowerPoint's own fullscreen slideshow mode (confirmed unreliable under automation/virtualization on both — same class of issue as Keynote'sstart/show); the editing view's current slide is enough for everything this app needs, since Program Out/NDI is what the audience actually sees - Live capture for Keynote/PowerPoint (macOS) — an alternative to the
static pre-exported-PNG frames above: captures the real on-screen display
live via
getDisplayMedia, so animations, transitions, and embedded video playback actually show up in the NDI output, not just a still image of however the slide looked at export time. Opt-in per stream (Program and/or Next Slide) via a gear icon next to each NDI toggle, which lists capturable screens and offers an optional crop region (X/Y/W/H %) for isolating a sub-area — e.g. just the "next slide" box out of a captured Presenter Display that shows current + next + notes together. Two things had to be fixed to make this reliable: the video element receiving the stream must be attached to the DOM (a detached one stops receiving frames after a few seconds — a Chromium resource-management heuristic based on visibility, confirmed live), and the window needsbackgroundThrottling: falseinwebPreferences, since Chromium throttlessetIntervalhard enough to stall the capture loop within seconds once backgrounded — which this app is, by design, during real use (Keynote/PowerPoint is what's actually frontmost while presenting). Fullscreen presentation windows aren't enumerable viadesktopCapturer's window-level sources on macOS (confirmed live — they sit above whatever window level that query reads), so this captures a whole physical display rather than a specific window, matching the existing Program Out display-picker's screen-based approach. - Canva integration — connects to a live Canva Presenter Window (opened
via Present → Presenter view) through the same browser-extension bridge as
Google Slides. Unlike Slides, Canva has no public API for speaker notes or
slide state, so both are scraped directly from the Presenter Window's DOM:
current slide index from its "N / M" counter, the frame from the untainted
<canvas>it renders slides onto (canvas.toDataURL(), nochrome.tabCaptureneeded), and notes text from a<span>Canva renders once notes are non-empty — the notes<textarea>itself only exists as an "Add notes…" placeholder prompt when notes are empty, so it can't be read directly. Navigating in our UI dispatches synthetic arrow-key events into the Presenter Window, mirroring Google Slides' approach - Presenter notes — per-slide notes, auto-saved to a
.notes.jsonsidecar file next to the PDF - Transport — Previous/Next buttons and arrow-key navigation
- Program Out — a second, fullscreen, chrome-free window showing just the current slide, for a projector or confidence monitor. Pick which connected display it opens on from a dropdown next to the button
- NDI Output — two independent, separately-toggleable NDI video sources
on the network: Program Out (the current slide) and Next Slide
NDI (the upcoming slide, using the same render path as the Now/Next
preview), so a second receiver — a stage monitor showing what's coming
next, a director's preview feed — can pick up the next slide without
needing the server's composited Confidence Monitor path. Built directly
against the official Vizrt NDI SDK
via a small native N-API addon (
native/ndi-send) — no third-party NDI wrapper. Both are independent of whether the Program Out window is open, since NDI is a network output rather than a local display - Server link — connects to the Master Server's client hub over
WebSocket (
ws://<host>:9800), registers itself by name, pushes live slide/notes state, and accepts remote next/previous-slide commands triggered from the server's Control Surface
graph LR
subgraph laptop["Presentation Laptop (Client Node — this app)"]
SS["SlideSource<br/>PDF · Keynote · PowerPoint · Google Slides · Canva"]
NDIsend["NDI Send<br/>native/ndi-send"]
ProgOut["Program Out window"]
SL["serverLink.ts"]
SS --> NDIsend
SS --> ProgOut
SS --> SL
end
subgraph browser["Browser (optional)"]
GSExt["Google Slides /<br/>Canva<br/>MV3 extension"]
end
GSExt -- "ws://localhost:9801" --> SL
subgraph server["Master Server"]
Hub["Client Hub<br/>WS :9800"]
Matrix["NDI Matrix /<br/>Scene Compositor"]
Auto["Automation API<br/>HTTP :9700"]
ConfMon["Confidence Monitor<br/>NDI Send"]
Hub --> Matrix
Matrix --> ConfMon
Auto --> Matrix
end
SL -- "register / slide-state / command" --> Hub
NDIsend -- "NDI network" --> Matrix
ConfMon -- "NDI network" --> Monitor["Physical stage<br/>confidence monitor"]
Companion["Bitfocus Companion<br/>module"] -- "GET /state, POST /rpc" --> Auto
Deck["Stream Deck / any<br/>Companion surface"] --> Companion
The native send addon links against the NDI SDK at build time. Install
the NDI SDK first (macOS
default: /Library/NDI SDK for Apple; override the location with
NDI_SDK_DIR if yours lives elsewhere). npm install rebuilds the addon
automatically via @electron/rebuild.
extension/ is one unpacked Chrome extension that lets the Client connect
to a live Google Slides Presenter View or a live Canva Presenter Window
instead of a local PDF/Keynote file — load it via chrome://extensions →
Developer mode → Load unpacked. Both platforms relay through the same local
WebSocket bridge (ws://localhost:9801); an app field on every message
tells the Client which SlideSource it belongs to. Fetching Google Slides
speaker notes needs a one-time OAuth client registration in Google Cloud
Console — click the ⚙ next to "Connect Google Slides…" in the app for
step-by-step in-app instructions and a field to paste the client ID
directly into extension/manifest.json (no manual file editing needed;
still requires one manual reload of the extension at chrome://extensions
afterward, since Chrome only picks up manifest changes on reload). The full
walkthrough is also written out at
extension/OAUTH_SETUP.md. Canva needs no
OAuth setup — its notes and slide state are scraped directly from the
Presenter Window's DOM, not fetched from an API.
Note for MV3 service worker reliability: the extension's background
service worker calls connect() defensively on every incoming message
rather than relying solely on its setTimeout-based reconnect loop —
Chrome discards pending timers when it suspends an idle service worker, so
a setTimeout reconnect scheduled just before suspension would otherwise
never fire.
Feature-complete for its current scope: the bespoke PDF engine, Keynote (macOS), PowerPoint (macOS and Windows), Google Slides, and Canva sources, dual NDI outputs (Program + Next Slide), live capture for Keynote/PowerPoint, presenter notes, Program Out window, and the Master Server link / Control Surface integration are all built and verified. Keynote drive and live capture are macOS-only (no Windows equivalent exists yet for either); PDF, Google Slides, and Canva work on both platforms.
npm installnpm run dev# Windows
npm run build:win
# macOS
npm run build:mac
# Linux
npm run build:linuxThe release builds are not code-signed or notarized — that needs paid Apple / Windows developer certificates this project doesn't carry. The app is safe to run; the OS just can't verify a publisher, so it warns you the first time. Here's how to get past that, and how to sign it yourself if you'd rather.
Delivered as a .dmg/.zip. On first launch macOS says the app "is damaged
and can't be opened" or "cannot be opened because the developer cannot be
verified" — that's Gatekeeper reacting to the missing signature, not an actual
problem.
Easiest fix: right-click (Control-click) the app in Applications → Open →
Open. You only do this once. If it still says "damaged" (common when the
.dmg came through a browser), clear the quarantine flag in Terminal:
xattr -dr com.apple.quarantine "/Applications/Presentation Commander Client.app"The installer is an unsigned .exe, so SmartScreen shows "Windows protected
your PC" → click More info → Run anyway. (Right-click → Properties →
Unblock also works.)
.AppImage: chmod +x it and run. .deb: sudo apt install ./<file>.deb. No
signing gate.
macOS ad-hoc (local only, not notarized):
codesign --force --deep --sign - "/Applications/Presentation Commander Client.app"To ship without warnings you need an Apple Developer Program membership
($99/yr) + a Developer ID Application certificate, then sign with the hardened
runtime and notarize with xcrun notarytool submit … --wait and
xcrun stapler staple. electron-builder does all of this for you if you set
CSC_LINK, CSC_KEY_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD and
APPLE_TEAM_ID. On Windows, clearing SmartScreen needs an Authenticode
code-signing certificate (signtool sign, or CSC_LINK/CSC_KEY_PASSWORD for
electron-builder).
