From debb75f519d7de2a4b6e0e1ffb1e565b0e6f4252 Mon Sep 17 00:00:00 2001 From: jankalthoefer Date: Thu, 20 Aug 2026 09:55:41 +0200 Subject: [PATCH 01/10] feat(highlighter): write install origin marker before installing Replay Streamlabs Replay cannot tell how it was installed on its own: Squirrel's Setup.exe launches the app directly, so there is no protocol link to read, and the installer carries no arguments. Parent apps therefore leave a marker file. Write %TEMP%\Streamlabs\install-origin.json with the sl_desktop origin before running the installer, per the contract in the highlighter repo. It has to precede the exec: Squirrel launches Replay at the end of the install, so Replay can resolve its origin while our exec call is still pending. The path is validated against the current user's temp directory first. Replay reads only that one location, so a SYSTEM or service identity would write somewhere it never looks. Elevation alone is fine. Best-effort by design: attribution is not worth failing an install over, so every failure is swallowed and only reported to Sentry. Co-Authored-By: Claude Opus 5 (1M context) --- app/services/highlighter/constants.ts | 11 ++++ app/services/highlighter/index.ts | 73 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/app/services/highlighter/constants.ts b/app/services/highlighter/constants.ts index 8d4d7bb6792f..f5a16535ee5c 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\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'; +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..28e684efa0e8 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'; @@ -507,6 +510,72 @@ export class HighlighterService extends PersistentStatefulService { + 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. + * + * 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(): Promise { + try { + const markerPath = this.getReplayInstallOriginMarkerPath(); + + // outputJson creates the containing directory if it does not exist yet + await fs.outputJson(markerPath, { + version: 1, + origin: REPLAY_INSTALL_ORIGIN, + createdAt: new Date().toISOString(), + }); + + // 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 + }"`, + ); + } 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, @@ -576,6 +645,10 @@ export class HighlighterService extends PersistentStatefulService Date: Wed, 26 Aug 2026 16:48:11 +0200 Subject: [PATCH 02/10] dev install path --- app/services/highlighter/constants.ts | 2 +- app/services/highlighter/index.ts | 83 +++++++++++++++++++++------ app/services/utils.ts | 3 + 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/app/services/highlighter/constants.ts b/app/services/highlighter/constants.ts index f5a16535ee5c..6f9c9a5e99b5 100644 --- a/app/services/highlighter/constants.ts +++ b/app/services/highlighter/constants.ts @@ -50,5 +50,5 @@ export const REPLAY_INSTALL_ORIGIN = 'sl_desktop'; // 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'; +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 28e684efa0e8..035a70d2d16a 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -510,6 +510,39 @@ 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. * @@ -620,17 +653,28 @@ export class HighlighterService extends PersistentStatefulService { - // Map download progress to 0-94% - const downloadPercent = progress.percent * 94; - this.setReplayDownloadProgress(downloadPercent); - }); + await downloadFile(setupUrl, setupPath, (progress: IDownloadProgress) => { + // Map download progress to 0-94% + const downloadPercent = progress.percent * 94; + this.setReplayDownloadProgress(downloadPercent); + }); + } clearProgress(); @@ -642,8 +686,13 @@ export class HighlighterService extends PersistentStatefulService Date: Thu, 27 Aug 2026 16:42:52 +0200 Subject: [PATCH 03/10] create marker with metadata --- .../highlighter/ImportStream.tsx | 35 +++++++++++------ .../highlighter/migration/MigrationNotice.tsx | 9 ++++- .../migration/ModalInstallationFlow.tsx | 6 ++- .../highlighter/migration/useInstallState.ts | 13 +++++-- app/services/highlighter/index.ts | 39 ++++++++++++++++--- .../highlighter/models/highlighter.models.ts | 16 ++++++++ 6 files changed, 95 insertions(+), 23 deletions(-) diff --git a/app/components-react/highlighter/ImportStream.tsx b/app/components-react/highlighter/ImportStream.tsx index 10bf49d3cced..977566a739c8 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.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/index.ts b/app/services/highlighter/index.ts index 035a70d2d16a..49d3fa97ae08 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -58,6 +58,7 @@ import { EHighlighterView, ITempRecordingInfo, IReplayInstallState, + IReplayInstallOriginMetadata, EReplayInstallStep, TOpenedFrom, } from './models/highlighter.models'; @@ -579,18 +580,39 @@ export class HighlighterService extends PersistentStatefulService { + private async writeReplayInstallOriginMarker( + metadata?: IReplayInstallOriginMetadata, + ): Promise { try { const markerPath = this.getReplayInstallOriginMarkerPath(); - // outputJson creates the containing directory if it does not exist yet + 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 @@ -598,7 +620,7 @@ export class HighlighterService extends PersistentStatefulService { @@ -613,10 +635,14 @@ export class HighlighterService extends PersistentStatefulService { + async installStreamlabsReplay(originMetadata?: IReplayInstallOriginMetadata): Promise { if (getOS() !== OS.Windows) { Sentry.withScope(scope => { scope.setTag('feature', 'highlighter'); @@ -694,9 +720,10 @@ export class HighlighterService extends PersistentStatefulService Date: Mon, 31 Aug 2026 12:28:51 +0200 Subject: [PATCH 04/10] correct comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- app/services/highlighter/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/highlighter/constants.ts b/app/services/highlighter/constants.ts index 6f9c9a5e99b5..a81caae615d7 100644 --- a/app/services/highlighter/constants.ts +++ b/app/services/highlighter/constants.ts @@ -46,7 +46,7 @@ export const REPLAY_SETUP_EXE_NAME = 'Streamlabs Highlighter-Setup.exe'; 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\install-origin.json) rather than in either app's data directory. It has to +// (%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. From c7082a8f8257702cb52262d3c797007b6fba737a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kalth=C3=B6fer?= Date: Mon, 31 Aug 2026 12:35:20 +0200 Subject: [PATCH 05/10] correct comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- app/services/highlighter/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 49d3fa97ae08..416e7ff4808b 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -581,9 +581,9 @@ export class HighlighterService extends PersistentStatefulService Date: Mon, 31 Aug 2026 12:35:44 +0200 Subject: [PATCH 06/10] add action to service call --- app/components-react/highlighter/ImportStream.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components-react/highlighter/ImportStream.tsx b/app/components-react/highlighter/ImportStream.tsx index 977566a739c8..8440d08c1996 100644 --- a/app/components-react/highlighter/ImportStream.tsx +++ b/app/components-react/highlighter/ImportStream.tsx @@ -157,7 +157,7 @@ export function ImportStreamModal({ if (!isInstalled) { setPendingImport({ game, filePath: filePath[0], streamId: id }); setShowingInstallFlow(true); - HighlighterService.installStreamlabsReplay({ videoPath: filePath[0], game }); + HighlighterService.actions.installStreamlabsReplay({ videoPath: filePath[0], game }); return; } From 340b348ac4933b8b2174b11898d137fed4c1098d Mon Sep 17 00:00:00 2001 From: jankalthoefer Date: Mon, 31 Aug 2026 12:35:56 +0200 Subject: [PATCH 07/10] correct comment --- app/services/highlighter/models/highlighter.models.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/highlighter/models/highlighter.models.ts b/app/services/highlighter/models/highlighter.models.ts index 5e6b95cc055c..833e0a97f074 100644 --- a/app/services/highlighter/models/highlighter.models.ts +++ b/app/services/highlighter/models/highlighter.models.ts @@ -55,15 +55,15 @@ export interface IReplayInstallState { * Extra hand-off data written into the install origin marker Replay reads on first run. * * Everything here is optional and best-effort: it describes what Desktop is about to ask Replay - * to do once the install finishes, so Replay can prepare for it before the deeplink arrives. + * to do once the install finishes, so Replay launch the onboarding with more context. */ export interface IReplayInstallOriginMetadata { /** * Absolute path of the recording the user picked in the import dialog — the same path Desktop - * sends via the `import` deeplink as soon as the install completes. + * sends via the `import` deeplink. */ videoPath?: string; - /** Game the user picked for that recording, sent via the same deeplink. */ + /** Game the user picked for that recording */ game?: EGame; } From 798e9c4a93a437b39cc43baf57cbfa72d5f3d906 Mon Sep 17 00:00:00 2001 From: jankalthoefer Date: Mon, 7 Sep 2026 09:58:07 +0200 Subject: [PATCH 08/10] rename --- .../highlighter/ImportStream.tsx | 12 +++++++++--- app/i18n/en-US/highlighter.json | 2 ++ app/services/highlighter/constants.ts | 16 ++++++++-------- app/services/highlighter/index.ts | 8 ++++---- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/app/components-react/highlighter/ImportStream.tsx b/app/components-react/highlighter/ImportStream.tsx index 8440d08c1996..b18e6c9abc7b 100644 --- a/app/components-react/highlighter/ImportStream.tsx +++ b/app/components-react/highlighter/ImportStream.tsx @@ -3,7 +3,7 @@ import { Services } from 'components-react/service-provider'; import { ListInput, TextInput } from 'components-react/shared/inputs'; import Form from 'components-react/shared/inputs/Form'; import * as remote from '@electron/remote'; -import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import { REPLAY_APP_NAME, SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; import { EGame } from 'services/highlighter/models/ai-highlighter.models'; import { IStreamInfoForAiHighlighter, @@ -377,9 +377,15 @@ export function ImportStreamModal({
{replayInstalled ? ( -

Continuing will open Streamlabs Highlighter

+

+ {' '} + {$t('Continuing will open %{appName}', { appName: REPLAY_APP_NAME })} +

) : ( -

Continuing will install Streamlabs Highlighter

+

+ {' '} + {$t('Continuing will install %{appName}', { appName: REPLAY_APP_NAME })} +

)}
diff --git a/app/i18n/en-US/highlighter.json b/app/i18n/en-US/highlighter.json index 8e60affd5198..86b15b66de38 100644 --- a/app/i18n/en-US/highlighter.json +++ b/app/i18n/en-US/highlighter.json @@ -184,6 +184,8 @@ "Open %{appName}": "Open %{appName}", "Install %{appName}": "Install %{appName}", "%{appName} has been installed and is now running.": "%{appName} has been installed and is now running.", + "Continuing will open %{appName}": "Continuing will open %{appName}", + "Continuing will install %{appName}": "Continuing will install %{appName}", "Installing Highlighter": "Installing Highlighter", "Installation interrupted": "Installation interrupted", "It seems the installation didn't finish.": "It seems the installation didn't finish.", diff --git a/app/services/highlighter/constants.ts b/app/services/highlighter/constants.ts index a81caae615d7..b6fb92d7f2b7 100644 --- a/app/services/highlighter/constants.ts +++ b/app/services/highlighter/constants.ts @@ -32,15 +32,15 @@ export const AI_HIGHLIGHTER_BUILDS_URL_STAGING = export const AI_HIGHLIGHTER_BUILDS_URL_PRODUCTION = 'https://cdn-highlighter-builds.streamlabs.com/production/manifest_win_x86_64.json'; -export const HIGHLIGHTER_SETUP_URL_STAGING = - 'https://cdn-highlighter-desktop.streamlabs.com/streamlabs-highlighter/staging/win32/x64/Streamlabs%20Highlighter-Setup.exe'; +export const REPLAY_SETUP_URL_STAGING = + 'https://cdn-highlighter-desktop.streamlabs.com/staging/win32/x64/G+HUB+Replay-Setup.exe'; -export const HIGHLIGHTER_SETUP_URL_PRODUCTION = - 'https://cdn-highlighter-desktop.streamlabs.com/streamlabs-highlighter/production/win32/x64/Streamlabs%20Highlighter-Setup.exe'; +export const REPLAY_SETUP_URL_PRODUCTION = + 'https://cdn-highlighter-desktop.streamlabs.com/production/win32/x64/G+HUB+Replay-Setup.exe'; -export const REPLAY_PROTOCOL = 'streamlabs-highlighter'; -export const REPLAY_APP_NAME = 'Streamlabs Highlighter'; -export const REPLAY_SETUP_EXE_NAME = 'Streamlabs Highlighter-Setup.exe'; +export const REPLAY_PROTOCOL = 'ghub-replay'; +export const REPLAY_APP_NAME = 'Replay'; +export const REPLAY_SETUP_EXE_NAME = 'G HUB Replay-Setup.exe'; // Origin slug Replay attributes an install to when Streamlabs Desktop installed it. export const REPLAY_INSTALL_ORIGIN = 'sl_desktop'; @@ -50,5 +50,5 @@ export const REPLAY_INSTALL_ORIGIN = 'sl_desktop'; // 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_DIR_NAME = 'GHUB_Replay'; 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 416e7ff4808b..983e7fb03042 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -15,8 +15,8 @@ import os from 'os'; import { SCRUB_SPRITE_DIRECTORY, SUPPORTED_FILE_TYPES, - HIGHLIGHTER_SETUP_URL_STAGING, - HIGHLIGHTER_SETUP_URL_PRODUCTION, + REPLAY_SETUP_URL_STAGING, + REPLAY_SETUP_URL_PRODUCTION, REPLAY_PROTOCOL, REPLAY_SETUP_EXE_NAME, REPLAY_INSTALL_ORIGIN, @@ -407,9 +407,9 @@ export class HighlighterService extends PersistentStatefulService Date: Mon, 7 Sep 2026 10:53:43 +0200 Subject: [PATCH 09/10] account for highlighter migration --- .../highlighter/ImportStream.tsx | 51 +++--- .../migration/ModalInstallationFlow.tsx | 24 +-- .../migration/PageInstallationFlow.tsx | 38 +++-- .../highlighter/migration/useInstallState.ts | 17 +- app/i18n/en-US/highlighter.json | 1 + app/services/highlighter/constants.ts | 9 +- app/services/highlighter/index.ts | 161 +++++++++++++----- .../highlighter/models/highlighter.models.ts | 10 ++ 8 files changed, 213 insertions(+), 98 deletions(-) diff --git a/app/components-react/highlighter/ImportStream.tsx b/app/components-react/highlighter/ImportStream.tsx index b18e6c9abc7b..63e5e17479fa 100644 --- a/app/components-react/highlighter/ImportStream.tsx +++ b/app/components-react/highlighter/ImportStream.tsx @@ -3,10 +3,15 @@ import { Services } from 'components-react/service-provider'; import { ListInput, TextInput } from 'components-react/shared/inputs'; import Form from 'components-react/shared/inputs/Form'; import * as remote from '@electron/remote'; -import { REPLAY_APP_NAME, SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import { + HIGHLIGHTER_APP_NAME, + REPLAY_APP_NAME, + SUPPORTED_FILE_TYPES, +} from 'services/highlighter/constants'; import { EGame } from 'services/highlighter/models/ai-highlighter.models'; import { IStreamInfoForAiHighlighter, + TInstalledHighlighterApp, TOpenedFrom, } from 'services/highlighter/models/highlighter.models'; import { $t } from 'services/i18n'; @@ -35,7 +40,7 @@ export function ImportStreamModal({ streamInfo?: IStreamInfoForAiHighlighter; }) { const { HighlighterService, UsageStatisticsService, IncrementalRolloutService } = Services; - const [replayInstalled, setReplayInstalled] = useState(null); + const [installedApp, setInstalledApp] = useState(null); const [showingInstallFlow, setShowingInstallFlow] = useState(false); const [pendingImport, setPendingImport] = useState<{ game: EGame; @@ -44,8 +49,8 @@ export function ImportStreamModal({ } | null>(null); useEffect(() => { - HighlighterService.isStreamlabsReplayInstalled().then(installed => { - setReplayInstalled(installed); + HighlighterService.getInstalledHighlighterApp().then(app => { + setInstalledApp(app); }); }, []); @@ -147,14 +152,17 @@ export function ImportStreamModal({ return; } - // 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 + // With neither app installed, 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) { + // + // With either app installed the import is deeplinked, and the service picks the protocol — + // a Highlighter user is sent to Highlighter, where their data still lives. + const app = await HighlighterService.getInstalledHighlighterApp(); + if (app === 'none') { setPendingImport({ game, filePath: filePath[0], streamId: id }); setShowingInstallFlow(true); HighlighterService.actions.installStreamlabsReplay({ videoPath: filePath[0], game }); @@ -195,7 +203,7 @@ export function ImportStreamModal({ closeModal(true); }} onInstallComplete={() => { - setReplayInstalled(true); + setInstalledApp('replay'); setShowingInstallFlow(false); // Deliberately no import deeplink here. The install origin marker already carried the @@ -222,7 +230,7 @@ export function ImportStreamModal({ // Show the install UI only once the user has committed to importing (game + title // selected, then startImport triggers the install). Applies to every entry point so // the import form is always shown first, never the install flow. - if (migrationEnabled && replayInstalled === false && showingInstallFlow) { + if (migrationEnabled && installedApp === 'none' && showingInstallFlow) { return ( {renderMigrationNotice()} @@ -376,17 +384,16 @@ export function ImportStreamModal({
- {replayInstalled ? ( -

- {' '} - {$t('Continuing will open %{appName}', { appName: REPLAY_APP_NAME })} -

- ) : ( -

- {' '} - {$t('Continuing will install %{appName}', { appName: REPLAY_APP_NAME })} -

- )} +

+ {' '} + {installedApp === 'replay' && + $t('Continuing will open %{appName}', { appName: REPLAY_APP_NAME })} + {installedApp === 'highlighter' && + $t('Continuing will open %{appName}', { appName: HIGHLIGHTER_APP_NAME })} + {installedApp !== 'replay' && + installedApp !== 'highlighter' && + $t('Continuing will install %{appName}', { appName: REPLAY_APP_NAME })} +

); diff --git a/app/components-react/highlighter/migration/ModalInstallationFlow.tsx b/app/components-react/highlighter/migration/ModalInstallationFlow.tsx index b33bcc609fde..0e5d4729e2d9 100644 --- a/app/components-react/highlighter/migration/ModalInstallationFlow.tsx +++ b/app/components-react/highlighter/migration/ModalInstallationFlow.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { Button } from 'antd'; import cx from 'classnames'; import styles from './MigrationNotice.m.less'; -import { REPLAY_APP_NAME } from 'services/highlighter/constants'; +import { HIGHLIGHTER_APP_NAME, REPLAY_APP_NAME } from 'services/highlighter/constants'; import { $t } from 'services/i18n'; import Translate from 'components-react/shared/Translate'; import SectionHeader from './SectionHeader'; @@ -21,6 +21,7 @@ export default function ModalInstallationFlow(props: IModalInstallationFlowProps const { step, progress, + installedApp, isInstalled, isInstalling, isRecorderRunning, @@ -29,6 +30,8 @@ export default function ModalInstallationFlow(props: IModalInstallationFlowProps handleCancel, } = useInstallState(props.installOriginMetadata); + const appName = installedApp === 'highlighter' ? HIGHLIGHTER_APP_NAME : REPLAY_APP_NAME; + useEffect(() => { if (step === 'done' && props.onInstallComplete) { props.onInstallComplete(); @@ -61,14 +64,11 @@ export default function ModalInstallationFlow(props: IModalInstallationFlowProps return (
- +

- {$t('Install %{appName} to import and detect game highlights.', { - appName: REPLAY_APP_NAME, - })} + {installedApp === 'highlighter' + ? $t('Open %{appName} to import and detect game highlights.', { appName }) + : $t('Install %{appName} to import and detect game highlights.', { appName })}

diff --git a/app/components-react/highlighter/migration/PageInstallationFlow.tsx b/app/components-react/highlighter/migration/PageInstallationFlow.tsx index baa583140163..50e36c8a4478 100644 --- a/app/components-react/highlighter/migration/PageInstallationFlow.tsx +++ b/app/components-react/highlighter/migration/PageInstallationFlow.tsx @@ -1,13 +1,16 @@ import React from 'react'; import { Button } from 'antd'; import cx from 'classnames'; -import { REPLAY_APP_NAME } from 'services/highlighter/constants'; +import { HIGHLIGHTER_APP_NAME, REPLAY_APP_NAME } from 'services/highlighter/constants'; import { $t } from 'services/i18n'; import Utils from 'services/utils'; import styles from './MigrationNotice.m.less'; import FeatureCarousel, { CAROUSEL_FEATURES } from './FeatureCarousel'; import { useInstallState, getStatusText } from './useInstallState'; -import { EReplayInstallStep } from 'services/highlighter/models/highlighter.models'; +import { + EReplayInstallStep, + TInstalledHighlighterApp, +} from 'services/highlighter/models/highlighter.models'; interface IPageInstallationFlowProps { onCancel: () => void; @@ -18,12 +21,14 @@ export default function PageInstallationFlow(props: IPageInstallationFlowProps) const { step, progress, - isInstalled, + installedApp, handleOpenOrInstall, handleRetry, handleCancel, } = useInstallState(); + const appName = installedApp === 'highlighter' ? HIGHLIGHTER_APP_NAME : REPLAY_APP_NAME; + function onCancel() { handleCancel(); props.onCancel(); @@ -34,7 +39,7 @@ export default function PageInstallationFlow(props: IPageInstallationFlowProps) return ( <> handleOpenOrInstall('page')} onRetry={handleRetry} onCancel={onCancel} @@ -80,7 +85,7 @@ export default function PageInstallationFlow(props: IPageInstallationFlowProps) interface IPageInstallCtaProps { step: EReplayInstallStep; progress: number; - isInstalled: boolean; + installedApp: TInstalledHighlighterApp; onOpenOrInstall: () => void; onShowAllClips: () => void; onRetry: () => void; @@ -90,7 +95,7 @@ interface IPageInstallCtaProps { function PageInstallCta({ step, progress, - isInstalled, + installedApp, onOpenOrInstall, onShowAllClips, onRetry, @@ -98,12 +103,16 @@ function PageInstallCta({ }: IPageInstallCtaProps) { const isInstalling = step === 'downloading' || step === 'installing' || step === 'verifying'; - // Idle — CTA button (install or open depending on whether Replay is already installed) + // Nothing to open yet — the only state where the CTA is an install and the handwritten + // annotations teasing it make sense. + const nothingInstalled = installedApp === 'none'; + + // Idle — CTA button: open whichever app the user has, or install Replay when they have neither if (step === 'idle') { return (
- {!isInstalled && ( + {nothingInstalled && (
- {isInstalled - ? $t('Open %{appName}', { appName: REPLAY_APP_NAME }) - : $t('Install %{appName}', { appName: REPLAY_APP_NAME })} - {!isInstalled && ( + {installedApp === 'replay' && $t('Open %{appName}', { appName: REPLAY_APP_NAME })} + {installedApp === 'highlighter' && + $t('Open %{appName}', { appName: HIGHLIGHTER_APP_NAME })} + {nothingInstalled && $t('Install %{appName}', { appName: REPLAY_APP_NAME })} + {nothingInstalled && (
- {isInstalled && ( + {!nothingInstalled && (