Skip to content
Open
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
35 changes: 23 additions & 12 deletions app/components-react/highlighter/ImportStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,17 @@ export function ImportStreamModal({
return;
}

// If Replay isn't installed yet, defer the import: stash the details, kick off the
// install, and replay it via onInstallComplete. This keeps every entry point (Go-live
// and the Highlighter page) on the same flow — game + title first, install second,
// then Replay opens directly on the import screen with the game and video.
// If Replay isn't installed yet, hand the import to the installer instead of deeplinking
// it: the video and game go into the install origin marker, and Replay picks them up on
// its first launch. This keeps every entry point (Go-live and the Highlighter page) on the
// same flow — game + title first, install second, then Replay opens directly on the import
// screen with the game and video. pendingImport is only kept so a retry writes the same
// marker and so onInstallComplete can close this modal.
const isInstalled = await HighlighterService.isStreamlabsReplayInstalled();
if (!isInstalled) {
setPendingImport({ game, filePath: filePath[0], streamId: id });
setShowingInstallFlow(true);
HighlighterService.installStreamlabsReplay();
HighlighterService.actions.installStreamlabsReplay({ videoPath: filePath[0], game });
return;
}

Expand Down Expand Up @@ -183,6 +185,11 @@ export function ImportStreamModal({
return (
<MigrationNotice
variant="modal"
installOriginMetadata={
pendingImport
? { videoPath: pendingImport.filePath, game: pendingImport.game }
: undefined
}
onCancel={() => {
setShowingInstallFlow(false);
closeModal(true);
Expand All @@ -191,15 +198,19 @@ export function ImportStreamModal({
setReplayInstalled(true);
setShowingInstallFlow(false);

// If there's a pending import, execute it now
// Deliberately no import deeplink here. The install origin marker already carried the
// video and game, and Replay acts on it when the installer launches it, so sending the
// link as well would open the same import a second time. Only the tracking the
// deeplink would have recorded is kept.
if (pendingImport) {
HighlighterService.actions.openReplayImport(
pendingImport.filePath,
pendingImport.game,
UsageStatisticsService.recordAnalyticsEvent('AIHighlighter', {
type: 'ReplayImport',
openedFrom,
pendingImport.streamId,
inputValue,
);
streamId: pendingImport.streamId,
game: pendingImport.game,
// Separates marker hand-offs from deeplinked imports in the ReplayImport numbers
via: 'install-marker',
});
setPendingImport(null);
closeModal(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { Services } from 'components-react/service-provider';
import ModalInstallationFlow from './ModalInstallationFlow';
import PageInstallationFlow from './PageInstallationFlow';
import { EAvailableFeatures } from 'services/incremental-rollout';
import { IReplayInstallOriginMetadata } from 'services/highlighter/models/highlighter.models';

interface IMigrationNoticeProps {
variant?: 'page' | 'modal';
onShowAllClips?: () => void;
onCancel?: () => void;
onInstallComplete?: () => void;
/** Hand-off data for the install origin marker, used when the user retries a failed install. */
installOriginMetadata?: IReplayInstallOriginMetadata;
}

export default function MigrationNotice(props: IMigrationNoticeProps) {
Expand Down Expand Up @@ -37,7 +40,11 @@ export default function MigrationNotice(props: IMigrationNoticeProps) {

if (variant === 'modal') {
return (
<ModalInstallationFlow onCancel={handleCancel} onInstallComplete={props.onInstallComplete} />
<ModalInstallationFlow
onCancel={handleCancel}
onInstallComplete={props.onInstallComplete}
installOriginMetadata={props.installOriginMetadata}
/>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import { $t } from 'services/i18n';
import Translate from 'components-react/shared/Translate';
import SectionHeader from './SectionHeader';
import { useInstallState, getStatusText } from './useInstallState';
import { IReplayInstallOriginMetadata } from 'services/highlighter/models/highlighter.models';

interface IModalInstallationFlowProps {
onCancel: () => void;
onInstallComplete?: () => void;
/** Hand-off data for the install origin marker, so a retry writes the same marker as the first
* attempt. */
installOriginMetadata?: IReplayInstallOriginMetadata;
}

export default function ModalInstallationFlow(props: IModalInstallationFlowProps) {
Expand All @@ -23,7 +27,7 @@ export default function ModalInstallationFlow(props: IModalInstallationFlowProps
handleOpenOrInstall,
handleRetry,
handleCancel,
} = useInstallState();
} = useInstallState(props.installOriginMetadata);

useEffect(() => {
if (step === 'done' && props.onInstallComplete) {
Expand Down
13 changes: 10 additions & 3 deletions app/components-react/highlighter/migration/useInstallState.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { useEffect, useState } from 'react';
import { Services } from 'components-react/service-provider';
import { useVuex } from 'components-react/hooks';
import { EReplayInstallStep } from 'services/highlighter/models/highlighter.models';
import {
EReplayInstallStep,
IReplayInstallOriginMetadata,
} from 'services/highlighter/models/highlighter.models';
import { REPLAY_APP_NAME } from 'services/highlighter/constants';
import { $t } from 'services/i18n';

export function useInstallState() {
/**
* @param installOriginMetadata - Hand-off data for the install origin marker, passed on again when
* the user retries a failed install so the retry writes the same marker as the first attempt.
*/
export function useInstallState(installOriginMetadata?: IReplayInstallOriginMetadata) {
const { HighlighterService } = Services;

const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
Expand Down Expand Up @@ -37,7 +44,7 @@ export function useInstallState() {
}

function handleRetry() {
HighlighterService.actions.installStreamlabsReplay();
HighlighterService.actions.installStreamlabsReplay(installOriginMetadata);
}

function handleCancel() {
Expand Down
11 changes: 11 additions & 0 deletions app/services/highlighter/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,14 @@ export const HIGHLIGHTER_SETUP_URL_PRODUCTION =
export const REPLAY_PROTOCOL = 'streamlabs-highlighter';
export const REPLAY_APP_NAME = 'Streamlabs Highlighter';
export const REPLAY_SETUP_EXE_NAME = 'Streamlabs Highlighter-Setup.exe';

// Origin slug Replay attributes an install to when Streamlabs Desktop installed it.
export const REPLAY_INSTALL_ORIGIN = 'sl_desktop';

// The install origin marker is a hand-off file, so it lives in the current user's temp directory
// (%TEMP%\Streamlabs_Highlighter\install-origin.json) rather than in either app's data directory. It has to
// work before Replay is installed at all, and Replay deletes it as soon as it has been read.
// Deliberately not derived from REPLAY_APP_NAME: this directory name is a contract with Replay and
// does not follow the app rename.
Comment thread
Copilot marked this conversation as resolved.
export const REPLAY_INSTALL_ORIGIN_DIR_NAME = 'Streamlabs_Highlighter';
export const REPLAY_INSTALL_ORIGIN_FILE_NAME = 'install-origin.json';
185 changes: 168 additions & 17 deletions app/services/highlighter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import {
HIGHLIGHTER_SETUP_URL_PRODUCTION,
REPLAY_PROTOCOL,
REPLAY_SETUP_EXE_NAME,
REPLAY_INSTALL_ORIGIN,
REPLAY_INSTALL_ORIGIN_DIR_NAME,
REPLAY_INSTALL_ORIGIN_FILE_NAME,
} from './constants';
import { pmap } from 'util/pmap';
import { RenderingClip } from './rendering/rendering-clip';
Expand Down Expand Up @@ -55,6 +58,7 @@ import {
EHighlighterView,
ITempRecordingInfo,
IReplayInstallState,
IReplayInstallOriginMetadata,
EReplayInstallStep,
TOpenedFrom,
} from './models/highlighter.models';
Expand Down Expand Up @@ -507,14 +511,138 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
}
}

/**
* Dev-only escape hatch for testing the install flow against a locally built Replay installer
* instead of the CDN one. Set HIGHLIGHTER_LOCAL_SETUP_PATH to the setup exe before launching, e.g.
*
* set "HIGHLIGHTER_LOCAL_SETUP_PATH=C:\path\to\Streamlabs Highlighter-0.0.16 Setup.exe"
*
* Read from remote.process.env at runtime (via Utils.env), so it takes effect on the next
* `yarn start` with no rebuild — unlike HIGHLIGHTER_ENV, which webpack bakes in at compile time.
*
* Gated on dev mode: a local build is not signed by Logitech, so this path skips the Authenticode
* check, and that check must stay unconditional in shipped builds.
*
* Throws if the path is set but missing, rather than silently falling back to the CDN download —
* a typo should be visible, not quietly ignored.
*/
private async getLocalReplaySetupPath(): Promise<string | null> {
if (!Utils.isDevMode()) return null;

const configuredPath = Utils.env.HIGHLIGHTER_LOCAL_SETUP_PATH?.trim();
if (!configuredPath) return null;

// Tolerate a value pasted with surrounding quotes, which is easy to do for a path with spaces
const setupPath = path.resolve(configuredPath.replace(/^"(.*)"$/, '$1'));

if (!(await fs.pathExists(setupPath))) {
throw new Error(
`HIGHLIGHTER_LOCAL_SETUP_PATH is set but no installer exists at "${setupPath}".`,
);
}

return setupPath;
}

/**
* Where Replay looks for the install origin marker, given who we are running as.
*
* Replay reads exactly one location: the current user's temp directory. A parent writing under a
* different identity gets a different %TEMP% — SYSTEM and services land in C:\Windows\TEMP or a
* profile under the Windows directory — and the marker would sit somewhere Replay never reads.
* Elevation alone is fine: "run as administrator" from the user's own account keeps the profile.
*
* Throws rather than returning a path we know Replay will not read.
*/
private getReplayInstallOriginMarkerPath(): string {
const isInside = (child: string, parent: string) => {
const relativePath = path.relative(parent, child);
return (
relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
);
};

const temp = remote.app.getPath('temp');
const home = remote.app.getPath('home');
const systemRoot = remote.process.env.SystemRoot ?? 'C:\\Windows';

if (!isInside(temp, home) || isInside(home, systemRoot)) {
throw new Error(`"${temp}" is not the desktop user's temp directory`);
}

return path.join(temp, REPLAY_INSTALL_ORIGIN_DIR_NAME, REPLAY_INSTALL_ORIGIN_FILE_NAME);
}

/**
* Writes the marker Streamlabs Replay reads on first run to attribute the install to
* Streamlabs Desktop.
*
* Must run before the installer is executed: Squirrel's Setup.exe launches Replay at the end of
* the install, so Replay can resolve its origin while our exec call is still pending.
*
* When the install was triggered from the import dialog, the marker also carries what the user
* picked there, under `metadata`: the recording and its game. Those are the exact values Desktop
* would otherwise pass via the `import` deeplink once the install finishes, so Replay can see it
* coming — the marker is the hand-off for that single import, not a second unrelated one.
*
* Best-effort by design. Attribution is never worth failing an install over, so every error is
* swallowed and only reported to Sentry.
*/
private async writeReplayInstallOriginMarker(
metadata?: IReplayInstallOriginMetadata,
): Promise<void> {
try {
const markerPath = this.getReplayInstallOriginMarkerPath();

const videoPath = metadata?.videoPath?.trim();
const game = metadata?.game;

// Only carry entries we actually have: an absent key is easier for Replay to reason about
// than one holding an empty value.
const markerMetadata = {
...(videoPath ? { videoPath } : {}),
...(game ? { game } : {}),
};
const hasMetadata = Object.keys(markerMetadata).length > 0;

// outputJson creates the containing directory if it does not exist yet.
// `metadata` itself is omitted when there is nothing to hand over, so Replay never has to
// tell an empty object apart from a missing one.
await fs.outputJson(markerPath, {
version: 1,
origin: REPLAY_INSTALL_ORIGIN,
createdAt: new Date().toISOString(),
...(hasMetadata ? { metadata: markerMetadata } : {}),
});

// Replay logs the path it looked at on every launch until the origin settles. Two paths that
// do not match is the whole diagnosis, so log ours and the identity that wrote it.
console.log(
`Wrote Streamlabs Replay install origin marker to "${markerPath}" as "${
os.userInfo().username
}"${hasMetadata ? ` with ${JSON.stringify(markerMetadata)}` : ''}`,
);
} catch (error: unknown) {
Sentry.withScope(scope => {
scope.setTag('feature', 'highlighter');
scope.setTag('replayInstallPhase', 'write-install-origin');
console.error('Failed to write Streamlabs Replay install origin marker:', error);
});
}
}

/**
* Downloads and installs Streamlabs Replay.
* Fakes progress increments during the download/install phases,
* verifies the deeplink registry after install, and auto-launches the app.
*
* @param originMetadata - Optional hand-off data for the install origin marker: the video and
* game the import dialog wants Replay to open with. Passing it here is what replaces the import
* deeplink — Replay reads the marker on its first launch, so nothing is sent afterwards.
*/
private replayInstallAbortController: AbortController | null = null;

async installStreamlabsReplay(): Promise<boolean> {
async installStreamlabsReplay(originMetadata?: IReplayInstallOriginMetadata): Promise<boolean> {
if (getOS() !== OS.Windows) {
Sentry.withScope(scope => {
scope.setTag('feature', 'highlighter');
Expand Down Expand Up @@ -551,17 +679,28 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
// --- Downloading phase ---
this.SET_REPLAY_INSTALL({ step: 'downloading', progress: 0, error: null });

const setupUrl = this.getReplaySetupUrl();
// Dev only. When set, this is a locally built installer we neither downloaded nor own,
// so the download, the signature check and the cleanup below are all skipped for it.
const localSetupPath = await this.getLocalReplaySetupPath();
let setupPath: string;

// Download the setup exe to temp directory
const tempDir = os.tmpdir();
const setupPath = path.join(tempDir, REPLAY_SETUP_EXE_NAME);
if (localSetupPath) {
console.info('Installing Streamlabs Replay from local build:', localSetupPath);
setupPath = localSetupPath;
this.setReplayDownloadProgress(94);
} else {
const setupUrl = this.getReplaySetupUrl();

await downloadFile(setupUrl, setupPath, (progress: IDownloadProgress) => {
// Map download progress to 0-94%
const downloadPercent = progress.percent * 94;
this.setReplayDownloadProgress(downloadPercent);
});
// Download the setup exe to temp directory
const tempDir = os.tmpdir();
setupPath = path.join(tempDir, REPLAY_SETUP_EXE_NAME);

await downloadFile(setupUrl, setupPath, (progress: IDownloadProgress) => {
// Map download progress to 0-94%
const downloadPercent = progress.percent * 94;
this.setReplayDownloadProgress(downloadPercent);
});
}

clearProgress();

Expand All @@ -573,8 +712,18 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
return false;
}

// Verify the Authenticode signature before execution
await this.verifyAuthenticodeSignature(setupPath);
if (localSetupPath) {
// A local build is not signed by Logitech, so the check would always fail here
console.warn('Skipping installer signature verification for local Streamlabs Replay build');
} else {
// Verify the Authenticode signature before execution
await this.verifyAuthenticodeSignature(setupPath);
}

// Attribute this install to Streamlabs Desktop before the installer runs, and hand over
// whatever we already know about what comes next (the video and game to import).
// Best-effort: this never throws and never blocks the install.
await this.writeReplayInstallOriginMarker(originMetadata);

// --- Installing phase ---
this.SET_REPLAY_INSTALL({ step: 'installing', progress: 94 });
Expand Down Expand Up @@ -636,11 +785,13 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
});
}

// Clean up setup file
try {
await fs.remove(setupPath);
} catch {
// Non-critical cleanup
// Clean up setup file. Never for a local build — that is the developer's own artifact.
if (!localSetupPath) {
try {
await fs.remove(setupPath);
} catch {
// Non-critical cleanup
}
}

// Track installation finished successfully
Expand Down
Loading
Loading