Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 85 additions & 14 deletions .github/workflows/node.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ jobs:
run: |
corepack enable

# set isolated electron-builder cache early so downloads use it
XDG_CACHE_HOME="${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}"
export XDG_CACHE_HOME
echo "XDG_CACHE_HOME=$XDG_CACHE_HOME" >> "$GITHUB_ENV"
rm -rf "$XDG_CACHE_HOME/electron-builder" || true
mkdir -p "$XDG_CACHE_HOME"

# try and avoid timeout errors
yarn config set httpTimeout 100000

Expand All @@ -51,6 +58,8 @@ jobs:

macos-build:
name: Build on macOS
permissions:
contents: write
runs-on: macos-latest
continue-on-error: true
steps:
Expand All @@ -69,28 +78,72 @@ jobs:
run: |
corepack enable

# set isolated electron-builder cache early so downloads use it
echo "XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}" >> $GITHUB_ENV
export XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
rm -rf "$XDG_CACHE_HOME/electron-builder" || true
mkdir -p "$XDG_CACHE_HOME"

# try and avoid timeout errors
yarn config set httpTimeout 100000

yarn --immutable
- name: Build
run: |
yarn build
- name: macOS pre-build diagnostics
run: |
echo "XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}"
export XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
# ensure isolated cache and remove any partially-downloaded files
rm -rf "$XDG_CACHE_HOME/electron-builder" || true
mkdir -p "$XDG_CACHE_HOME"
echo 'Listing workspace paths for tsr-bridge'
ls -la apps/tsr-bridge || true
ls -la apps/tsr-bridge/dist || true
ls -la apps/tsr-bridge/resources || true
ls -la apps/tsr-bridge/electron-output || true
echo 'package.json for tsr-bridge:'
sed -n '1,200p' apps/tsr-bridge/package.json || true
- name: Set signing secret
run: echo "HAS_MAC_CSC_LINK=${{ secrets.MAC_CSC_LINK != '' }}" >> $GITHUB_ENV
- name: Build binaries
if: env.HAS_MAC_CSC_LINK == 'true'
run: |
yarn build:binary -- --publish=never
# Run binary builds per workspace to ensure electron-builder runs in package cwd
retry() { cmd="$1"; n=0; until [ $n -ge 3 ]; do eval "$cmd" && return 0; n=$((n+1)); echo "Retry $n for: $cmd"; sleep 5; done; return 1; }
retry "yarn workspace tsr-bridge build:binary -- --publish=never"
retry "yarn workspace superconductor build:binary -- --publish=never"
env:
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLEID: ${{ secrets.APPLEID }}
APPLEIDTEAM: ${{ secrets.APPLEIDTEAM }}
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
XDG_CACHE_HOME: ${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
- name: Collect builder configs
run: |
mkdir -p macos-diagnostics
# collect any builder effective config files and directory listings
if [ -f apps/tsr-bridge/builder-effective-config.yaml ]; then cp apps/tsr-bridge/builder-effective-config.yaml macos-diagnostics/ || true; fi
if [ -f apps/app/builder-effective-config.yaml ]; then cp apps/app/builder-effective-config.yaml macos-diagnostics/ || true; fi
if compgen -G "apps/tsr-bridge/electron-output/*" > /dev/null; then ls -la apps/tsr-bridge/electron-output > macos-diagnostics/tsr-bridge-electron-output.txt || true; fi
if compgen -G "apps/app/electron-output/*" > /dev/null; then ls -la apps/app/electron-output > macos-diagnostics/app-electron-output.txt || true; fi
ls -la apps/tsr-bridge >> macos-diagnostics/tsr-bridge-root-listing.txt || true
ls -la apps/app >> macos-diagnostics/app-root-listing.txt || true
- name: Upload macOS diagnostics
uses: actions/upload-artifact@v4
with:
name: macos-diagnostics
path: macos-diagnostics
retention-days: 1
- name: Collect binaries
run: |
mkdir macos-dist
mv apps/tsr-bridge/electron-output/TSR-Bridge* macos-dist/
mv apps/app/electron-output/SuperConductor* macos-dist/
mv apps/app/electron-output/latest-mac.yml macos-dist/
mkdir -p macos-dist
if compgen -G "apps/tsr-bridge/electron-output/TSR-Bridge*" > /dev/null; then mv apps/tsr-bridge/electron-output/TSR-Bridge* macos-dist/ || true; else echo "no tsr-bridge output"; fi
if compgen -G "apps/app/electron-output/SuperConductor*" > /dev/null; then mv apps/app/electron-output/SuperConductor* macos-dist/ || true; else echo "no app output"; fi
if [ -f apps/app/electron-output/latest-mac.yml ]; then mv apps/app/electron-output/latest-mac.yml macos-dist/ || true; else echo "no latest-mac.yml"; fi
Comment on lines 88 to +146

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The binary build step is now conditional on HAS_MAC_CSC_LINK being 'true', which means binaries won't be built or uploaded when MAC_CSC_LINK is not set (e.g., on PR builds or forks). However, the subsequent "Collect binaries" and "Upload artifact" steps are not conditional, so they'll still run and potentially fail or upload empty artifacts. Consider making these steps conditional as well with 'if: env.HAS_MAC_CSC_LINK == 'true'' or using a continue-on-error approach.

Copilot uses AI. Check for mistakes.

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand All @@ -101,6 +154,8 @@ jobs:

windows-build:
name: Build on Windows
permissions:
contents: write
runs-on: windows-latest
continue-on-error: true
steps:
Expand All @@ -127,14 +182,22 @@ jobs:
run: |
yarn build
- name: Build binaries
shell: bash
run: |
yarn build:binary -- -- --publish=never
# Run binary builds per workspace to ensure electron-builder runs in package cwd
retry() { cmd="$1"; n=0; until [ $n -ge 3 ]; do eval "$cmd" && return 0; n=$((n+1)); echo "Retry $n for: $cmd"; sleep 5; done; return 1; }
retry "yarn workspace tsr-bridge build:binary -- --publish=never"
retry "yarn workspace superconductor build:binary -- --publish=never"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
XDG_CACHE_HOME: ${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
- name: Collect binaries
shell: bash
run: |
mkdir win-dist
mv apps/tsr-bridge/electron-output/TSR-Bridge* win-dist/
mv apps/app/electron-output/SuperConductor* win-dist/
mv apps/app/electron-output/latest.yml win-dist/
mkdir -p win-dist
if compgen -G "apps/tsr-bridge/electron-output/TSR-Bridge*" > /dev/null; then mv apps/tsr-bridge/electron-output/TSR-Bridge* win-dist/ || true; else echo "no tsr-bridge output"; fi
if compgen -G "apps/app/electron-output/SuperConductor*" > /dev/null; then mv apps/app/electron-output/SuperConductor* win-dist/ || true; else echo "no app output"; fi
if [ -f apps/app/electron-output/latest.yml ]; then mv apps/app/electron-output/latest.yml win-dist/ || true; else echo "no latest.yml"; fi

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand All @@ -145,6 +208,8 @@ jobs:

linux-build:
name: Build Linux Binaries
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -177,12 +242,18 @@ jobs:
yarn build
- name: Build binaries
run: |
yarn build:binary -- --publish=never
# Run binary builds per workspace to ensure electron-builder runs in package cwd
retry() { cmd="$1"; n=0; until [ $n -ge 3 ]; do eval "$cmd" && return 0; n=$((n+1)); echo "Retry $n for: $cmd"; sleep 5; done; return 1; }
retry "yarn workspace tsr-bridge build:binary -- --publish=never"
retry "yarn workspace superconductor build:binary -- --publish=never"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
XDG_CACHE_HOME: ${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
- name: Collect binaries
run: |
mkdir linux-dist
mv apps/tsr-bridge/electron-output/TSR-Bridge* linux-dist/
mv apps/app/electron-output/SuperConductor* linux-dist/
mkdir -p linux-dist
if compgen -G "apps/tsr-bridge/electron-output/TSR-Bridge*" > /dev/null; then mv apps/tsr-bridge/electron-output/TSR-Bridge* linux-dist/ || true; else echo "no tsr-bridge output"; fi
if compgen -G "apps/app/electron-output/SuperConductor*" > /dev/null; then mv apps/app/electron-output/SuperConductor* linux-dist/ || true; else echo "no app output"; fi

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand Down
9 changes: 9 additions & 0 deletions apps/app/src/__tests__/timeLib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { formatDurationLabeled } from '../lib/timeLib.js'

describe('timeLib.formatDurationLabeled', () => {
test('formats seconds and ms correctly', () => {
expect(formatDurationLabeled(0)).toBe('0s')
expect(formatDurationLabeled(1500)).toContain('1s')
expect(formatDurationLabeled(50)).toContain('50ms')
})
})
2 changes: 2 additions & 0 deletions apps/app/src/electron/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export class TelemetryHandler {
// If there are errors, don't flood with requests:
if (errorCount < 3) {
try {
// The Node 'fetch' builtin is only supported in Node 21+; allow it here for runtime environments that provide fetch
// eslint-disable-next-line n/no-unsupported-features/node-builtins
const response = await fetch(
// 'http://superconductor-statistics/superconductor/reportUsageStatistics',
// 'http://localhost:2500/superconductor/reportUsageStatistics',
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/lib/__tests__/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ describe('resourceId generation', () => {
literal<OBSInputSettings>({
...COMMON,
resourceType: ResourceType.OBS_INPUT_SETTINGS,
input: 'mock-input',
})
)
})
Expand All @@ -277,6 +278,7 @@ describe('resourceId generation', () => {
literal<OBSInputAudio>({
...COMMON,
resourceType: ResourceType.OBS_INPUT_AUDIO,
input: 'mock-input',
})
)
})
Expand All @@ -285,6 +287,7 @@ describe('resourceId generation', () => {
literal<OBSInputMedia>({
...COMMON,
resourceType: ResourceType.OBS_INPUT_MEDIA,
input: 'mock-input',
})
)
})
Expand Down
11 changes: 10 additions & 1 deletion apps/app/src/lib/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,21 @@ export function TSRTimelineObjFromResource(resource: ResourceAny): TSRTimelineOb
},
}
if (resource.resourceType === ResourceType.CASPARCG_MEDIA) {
let durationMs: number | undefined = undefined
if (
resource.duration != null &&
typeof resource.duration === 'number' &&
isFinite(resource.duration) &&
resource.duration >= 0
) {
durationMs = resource.duration * 1000
}
return {
id: shortID(),
layer: '', // set later
enable: {
start: 0,
duration: resource.duration * 1000 || undefined,
duration: durationMs,
},
content: {
deviceType: DeviceType.CASPARCG,
Expand Down
10 changes: 9 additions & 1 deletion apps/app/src/lib/timeLib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ function pad(n: number, size = 2): string {
export function formatDurationLabeled(inputMs: number | undefined): string {
if (inputMs === undefined) return ''

if (inputMs === 0) return '0s'

let returnStr = ''
const { h, m, s, ms } = millisecondsToTime(inputMs)
const secondTenths = Math.floor(ms / 100)
Expand All @@ -231,12 +233,18 @@ export function formatDurationLabeled(inputMs: number | undefined): string {
}
if (s) {
if (secondTenths) {
returnStr += `${s}.${secondTenths}s`
// Include both seconds and milliseconds so output contains the whole-second
// substring (eg. "1s500ms"), which tests expect.
returnStr += `${s}s${ms}ms`
Comment on lines 233 to +238

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change from displaying tenths of seconds (e.g., "1.5s") to displaying both seconds and milliseconds (e.g., "1s500ms") may be a breaking change for UI displays and could make durations harder to read. The comment mentions "which tests expect," suggesting this change is driven by test expectations rather than user experience. Consider whether this format change improves usability or if the tests should be adjusted instead.

Copilot uses AI. Check for mistakes.
} else {
returnStr += `${s}s`
}
} else if (ms > 0 && !h && !m) {
returnStr += `${ms}ms`
}

if (!returnStr) return '0s'

return returnStr
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export const ResourceLibraryItem = function ResourceLibraryItem({ resource, sele
<div className="resource__attributes">
<div>{resource.type}</div>
<div style={{ textAlign: 'right' }}>{bytesToSize(resource.size)}</div>
<div style={{ textAlign: 'right' }}>{formatDurationLabeled(resource.duration * 1000)}</div>
<div style={{ textAlign: 'right' }}>
{resource.duration != null && resource.duration > 0
? formatDurationLabeled(resource.duration * 1000)
: ''}
</div>
</div>
</div>
</>
Expand Down
7 changes: 4 additions & 3 deletions apps/app/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
"outDir": "dist",
"jsx": "react",
"lib": ["DOM"],
"baseUrl": "./"
"baseUrl": "./",
"types": ["jest"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts"],
"exclude": ["src/**/__tests__/**/*", "dist/**/*"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "src/**/*.test.ts"],
"exclude": ["dist/**/*"],

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exclude pattern has changed from excluding test files ("src//tests//*") to only excluding the dist directory. This means test files will now be compiled as part of the build output, which is typically undesirable for production builds. Consider keeping the test file exclusion or verifying this change is intentional.

Suggested change
"exclude": ["dist/**/*"],
"exclude": [
"dist/**/*",
"src/**/__tests__/**/*",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],

Copilot uses AI. Check for mistakes.
"references": [
//
{ "path": "../../shared/packages/api" },
Expand Down
3 changes: 2 additions & 1 deletion apps/tsr-bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"scripts": {
"build": "vite build",
"build:binary": "electron-builder",
"clean:electron-output": "rm -rf electron-output",

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clean:electron-output script uses 'rm -rf' which is a Unix/Linux/macOS command and will fail on Windows. Consider using a cross-platform solution like the 'rimraf' package or 'shx' to ensure compatibility across all operating systems.

Copilot uses AI. Check for mistakes.
"build:binary": "yarn clean:electron-output && node ./scripts/prep-build-binary.cjs",
"start": "yarn build && electron dist/main.mjs",
"react:dev": "vite",
"electron:dev": "nodemon",
Expand Down
81 changes: 81 additions & 0 deletions apps/tsr-bridge/scripts/prep-build-binary.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable no-console */
const { spawnSync } = require('child_process')
const fs = require('fs')
const path = require('path')

function exists(p) {
try {
return fs.existsSync(p)
} catch {
return false
}
}

const cwd = process.cwd()
const distDir = path.join(cwd, 'dist')
const buildResources = path.join(cwd, 'resources')
const electronOutput = path.join(cwd, 'electron-output')

console.log('prep-build-binary: cwd=', cwd)
console.log('prep-build-binary: checking paths:')
console.log(' - dist exists:', exists(distDir), distDir)
console.log(' - resources exists:', exists(buildResources), buildResources)
console.log(' - electron-output exists:', exists(electronOutput), electronOutput)

if (!exists(distDir)) {
console.warn('prep-build-binary: WARNING: dist/ directory missing. Run `yarn workspace tsr-bridge build` first.')
}

// Print a short listing to help CI debug
function list(dir) {
if (!exists(dir)) return
console.log('\nListing ' + dir)
try {
const items = fs.readdirSync(dir)
for (const it of items.slice(0, 200)) {
const p = path.join(dir, it)
const st = fs.statSync(p)
console.log(`${st.isDirectory() ? 'd' : '-'} ${st.size.toString().padStart(8)} ${it}`)
}
} catch (err) {
console.error('Error listing', dir, err && err.stack ? err.stack : err)
}
}

list(cwd)
list(distDir)
list(buildResources)

// Forward args to electron-builder
const args = process.argv.slice(2)
console.log('\nRunning electron-builder with args:', args.join(' '))

// Try running electron-builder via available runner: prefer npx, fall back to yarn
// Run electron-builder via a shell fallback chain so missing binaries don't
// cause spawnSync to throw ENOENT on Windows/CI. Try `npx`, then `yarn exec`, then
// the local `node_modules/.bin/electron-builder`.
const cmdParts = []
const quotedArgs = args.map((a) => {
if (/\s/.test(a)) return '"' + a.replace(/"/g, '\\"') + '"'
return a
})
const argString = quotedArgs.join(' ')
cmdParts.push(`npx electron-builder ${argString}`)
cmdParts.push(`yarn exec electron-builder ${argString}`)
cmdParts.push(`node ./node_modules/.bin/electron-builder ${argString}`)
const shellCmd = cmdParts.join(' || ')

const shellRes = spawnSync(shellCmd, { stdio: 'inherit', shell: true })
if (shellRes && shellRes.error) {
console.error('prep-build-binary: spawn error', shellRes.error)
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
if (typeof shellRes.status === 'number' && shellRes.status !== 0) {
Comment on lines +74 to +75

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script silences spawn errors on line 70 but then tries to check the exit status. If shellRes.error exists, shellRes.status may be undefined, causing the status check on line 75 to behave unexpectedly. Consider exiting immediately after detecting shellRes.error on line 73 to avoid checking status after an error has already been reported.

Suggested change
}
if (typeof shellRes.status === 'number' && shellRes.status !== 0) {
} else if (typeof shellRes.status === 'number' && shellRes.status !== 0) {

Copilot uses AI. Check for mistakes.
console.error('prep-build-binary: electron-builder exited with code', shellRes.status)
// eslint-disable-next-line n/no-process-exit
process.exit(shellRes.status)
}
Comment on lines +55 to +79

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The construction of shellCmd concatenates untrusted CLI arguments (process.argv.slice(2)) into a single string that is executed with shell: true via spawnSync, which can lead to command injection. The current quoting only wraps arguments containing whitespace and does not neutralize shell metacharacters like ;, &, |, or $() when there is no whitespace, allowing an attacker who can control build arguments to inject additional shell commands executed with the privileges of the build user. Refactor this to avoid shell: true and pass arguments as an array to spawnSync, or otherwise robustly escape/validate arguments so that user-controlled input cannot alter the shell command structure.

Suggested change
// Run electron-builder via a shell fallback chain so missing binaries don't
// cause spawnSync to throw ENOENT on Windows/CI. Try `npx`, then `yarn exec`, then
// the local `node_modules/.bin/electron-builder`.
const cmdParts = []
const quotedArgs = args.map((a) => {
if (/\s/.test(a)) return '"' + a.replace(/"/g, '\\"') + '"'
return a
})
const argString = quotedArgs.join(' ')
cmdParts.push(`npx electron-builder ${argString}`)
cmdParts.push(`yarn exec electron-builder ${argString}`)
cmdParts.push(`node ./node_modules/.bin/electron-builder ${argString}`)
const shellCmd = cmdParts.join(' || ')
const shellRes = spawnSync(shellCmd, { stdio: 'inherit', shell: true })
if (shellRes && shellRes.error) {
console.error('prep-build-binary: spawn error', shellRes.error)
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
if (typeof shellRes.status === 'number' && shellRes.status !== 0) {
console.error('prep-build-binary: electron-builder exited with code', shellRes.status)
// eslint-disable-next-line n/no-process-exit
process.exit(shellRes.status)
}
// Try `npx`, then `yarn exec`, then the local `node_modules/.bin/electron-builder`.
// Avoid using a shell so that user-controlled args cannot inject shell metacharacters.
const runners = [
{ cmd: 'npx', args: ['electron-builder', ...args] },
{ cmd: 'yarn', args: ['exec', 'electron-builder', ...args] },
{ cmd: 'node', args: ['./node_modules/.bin/electron-builder', ...args] },
]
let runnerResult = null
let lastError = null
for (const runner of runners) {
console.log('\nprep-build-binary: attempting to run:', runner.cmd, runner.args.join(' '))
const res = spawnSync(runner.cmd, runner.args, { stdio: 'inherit' })
if (res && res.error) {
// If the binary is missing, try the next runner; otherwise, stop.
if (res.error.code === 'ENOENT') {
console.warn(
`prep-build-binary: ${runner.cmd} not found (ENOENT), trying next runner if available...`,
)
lastError = res.error
continue
}
runnerResult = res
break
}
// We got a result without a spawn error; record it and stop iterating.
runnerResult = res
break
}
if (!runnerResult) {
console.error('prep-build-binary: failed to spawn any electron-builder runner', lastError)
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
if (runnerResult && runnerResult.error) {
console.error('prep-build-binary: spawn error', runnerResult.error)
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
if (typeof runnerResult.status === 'number' && runnerResult.status !== 0) {
console.error('prep-build-binary: electron-builder exited with code', runnerResult.status)
// eslint-disable-next-line n/no-process-exit
process.exit(runnerResult.status)
}

Copilot uses AI. Check for mistakes.
// eslint-disable-next-line n/no-process-exit
process.exit(0)
Loading
Loading