diff --git a/app/components-react/highlighter/ImportStream.tsx b/app/components-react/highlighter/ImportStream.tsx index 10bf49d3cced..8440d08c1996 100644 --- a/app/components-react/highlighter/ImportStream.tsx +++ b/app/components-react/highlighter/ImportStream.tsx @@ -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; } @@ -183,6 +185,11 @@ export function ImportStreamModal({ return ( { setShowingInstallFlow(false); closeModal(true); @@ -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); } diff --git a/app/components-react/highlighter/migration/MigrationNotice.tsx b/app/components-react/highlighter/migration/MigrationNotice.tsx index 654bef52fa6e..95e9d706ec1e 100644 --- a/app/components-react/highlighter/migration/MigrationNotice.tsx +++ b/app/components-react/highlighter/migration/MigrationNotice.tsx @@ -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) { @@ -37,7 +40,11 @@ export default function MigrationNotice(props: IMigrationNoticeProps) { if (variant === 'modal') { return ( - + ); } diff --git a/app/components-react/highlighter/migration/ModalInstallationFlow.tsx b/app/components-react/highlighter/migration/ModalInstallationFlow.tsx index 3bb302d97f37..b33bcc609fde 100644 --- a/app/components-react/highlighter/migration/ModalInstallationFlow.tsx +++ b/app/components-react/highlighter/migration/ModalInstallationFlow.tsx @@ -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) { @@ -23,7 +27,7 @@ export default function ModalInstallationFlow(props: IModalInstallationFlowProps handleOpenOrInstall, handleRetry, handleCancel, - } = useInstallState(); + } = useInstallState(props.installOriginMetadata); useEffect(() => { if (step === 'done' && props.onInstallComplete) { diff --git a/app/components-react/highlighter/migration/useInstallState.ts b/app/components-react/highlighter/migration/useInstallState.ts index 580b04d9e4e0..880de45c87c8 100644 --- a/app/components-react/highlighter/migration/useInstallState.ts +++ b/app/components-react/highlighter/migration/useInstallState.ts @@ -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(null); @@ -37,7 +44,7 @@ export function useInstallState() { } function handleRetry() { - HighlighterService.actions.installStreamlabsReplay(); + HighlighterService.actions.installStreamlabsReplay(installOriginMetadata); } function handleCancel() { diff --git a/app/services/highlighter/constants.ts b/app/services/highlighter/constants.ts index 8d4d7bb6792f..a81caae615d7 100644 --- a/app/services/highlighter/constants.ts +++ b/app/services/highlighter/constants.ts @@ -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. +export const REPLAY_INSTALL_ORIGIN_DIR_NAME = 'Streamlabs_Highlighter'; +export const REPLAY_INSTALL_ORIGIN_FILE_NAME = 'install-origin.json'; diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 6c6d5ce6fe62..416e7ff4808b 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -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'; @@ -55,6 +58,7 @@ import { EHighlighterView, ITempRecordingInfo, IReplayInstallState, + IReplayInstallOriginMetadata, EReplayInstallStep, TOpenedFrom, } from './models/highlighter.models'; @@ -507,14 +511,138 @@ export class HighlighterService extends PersistentStatefulService { + 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 { + 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 { + async installStreamlabsReplay(originMetadata?: IReplayInstallOriginMetadata): Promise { if (getOS() !== OS.Windows) { Sentry.withScope(scope => { scope.setTag('feature', 'highlighter'); @@ -551,17 +679,28 @@ export class HighlighterService extends PersistentStatefulService { - // 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(); @@ -573,8 +712,18 @@ export class HighlighterService extends PersistentStatefulService