From 6eb77df1aa841a482334e1ed8341ef9964d0f497 Mon Sep 17 00:00:00 2001 From: Michael Nguyen Date: Sun, 23 Aug 2026 22:05:17 +0700 Subject: [PATCH] fix(desktop): route SSH media through active connection --- .../electron/connection-config.test.ts | 22 +++++ apps/desktop/electron/connection-config.ts | 18 ++++ .../electron/gateway-file-download.test.ts | 55 +++++++++++- .../desktop/electron/gateway-file-download.ts | 28 +++++++ apps/desktop/electron/main.ts | 83 ++++++++++++++----- apps/desktop/electron/media-protocol.test.ts | 28 ++++++- apps/desktop/electron/media-protocol.ts | 29 +++++-- apps/desktop/src/global.d.ts | 7 +- apps/desktop/src/lib/desktop-fs.test.ts | 21 +++++ apps/desktop/src/lib/desktop-fs.ts | 3 +- apps/desktop/src/lib/media.remote.test.ts | 16 +++- apps/desktop/src/lib/media.ts | 11 ++- 12 files changed, 286 insertions(+), 35 deletions(-) diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 058aa3e9de..0f7c19bcdd 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -35,6 +35,7 @@ import { normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, + pathForRegistryBackendRequest, pathWithGlobalRemoteProfile, pathWithProfileScope, profileHasRemoteConnection, @@ -484,6 +485,27 @@ test('pathWithProfileScope keeps an explicit profile query and no-ops on empty p assert.equal(pathWithProfileScope('/api/cron/jobs', null), '/api/cron/jobs') }) +test('pathForRegistryBackendRequest uses the resolved registry backend scope', () => { + assert.equal( + pathForRegistryBackendRequest('/api/fs/read-data-url?path=%2Fsrv%2Fimage.png', 'research', { + sharedRemote: true + }), + '/api/fs/read-data-url?path=%2Fsrv%2Fimage.png&profile=research' + ) + assert.equal( + pathForRegistryBackendRequest('/api/fs/download?path=%2Fsrv%2Freport.pdf&profile=mara', 'mara', { + remoteProfile: 'default' + }), + '/api/fs/download?path=%2Fsrv%2Freport.pdf&profile=default' + ) + assert.equal( + pathForRegistryBackendRequest('/api/fs/download?path=%2Fsrv%2Freport.pdf', 'mara', { + remoteProfile: 'default' + }), + '/api/fs/download?path=%2Fsrv%2Freport.pdf' + ) +}) + // --- pathWithGlobalRemoteProfile --- test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 89491fa8ab..590f34473b 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -811,6 +811,23 @@ function pathWithProfileScope(path, profile) { return `${parsed.pathname}${parsed.search}${parsed.hash}` } +export interface RegistryBackendRequestScope { + remoteProfile?: null | string + sharedRemote?: boolean +} + +/** + * Scope a REST path for a resolved registry backend. Shared remotes serve + * multiple profiles from one process and need an explicit profile query; + * isolated SSH backends already own one profile but may translate a Desktop + * alias in an existing self-profile filter. + */ +function pathForRegistryBackendRequest(path, profile, backend: RegistryBackendRequestScope) { + return backend.sharedRemote + ? pathWithProfileScope(path, profile) + : translateSelfProfileQuery(path, profile, backend.remoteProfile) +} + /** * Registry connection a REST request is explicitly pinned to, or null for the * legacy profile-routed path. An explicit `local` id must stay registry-scoped: @@ -985,6 +1002,7 @@ export { normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, + pathForRegistryBackendRequest, pathWithGlobalRemoteProfile, pathWithProfileScope, PRIVY_ACCESS_COOKIE_VARIANTS, diff --git a/apps/desktop/electron/gateway-file-download.test.ts b/apps/desktop/electron/gateway-file-download.test.ts index a9d054b1fa..d67c763991 100644 --- a/apps/desktop/electron/gateway-file-download.test.ts +++ b/apps/desktop/electron/gateway-file-download.test.ts @@ -8,7 +8,8 @@ import { gatewayFilePath, isNotFoundError, parseDataUrlToBuffer, - pumpStreamToFile + pumpStreamToFile, + resolveGatewayFileBackend } from './gateway-file-download' // A Readable-like response driven manually in tests. @@ -188,3 +189,55 @@ test('isNotFoundError matches only HTTP 404', () => { assert.equal(isNotFoundError(new Error('plain')), false) assert.equal(isNotFoundError(null), false) }) + +test('resolveGatewayFileBackend pins registered files to their owning connection', async () => { + const calls: string[] = [] + + const route = await resolveGatewayFileBackend( + { connectionId: ' work-ssh ', profile: ' default ' }, + { + ensureLegacy: async profile => { + calls.push(`legacy:${profile}`) + + return { baseUrl: 'http://local.invalid' } + }, + ensureRegistry: async (connectionId, profile) => { + calls.push(`registry:${connectionId}:${profile}`) + + return { baseUrl: 'http://ssh.invalid' } + } + } + ) + + assert.deepEqual(calls, ['registry:work-ssh:default']) + assert.deepEqual(route, { + connection: { baseUrl: 'http://ssh.invalid' }, + connectionId: 'work-ssh', + profile: 'default' + }) +}) + +test('resolveGatewayFileBackend preserves the legacy route when no connection owns the file', async () => { + const calls: string[] = [] + + const route = await resolveGatewayFileBackend( + { profile: 'coder' }, + { + ensureLegacy: async profile => { + calls.push(`legacy:${profile}`) + + return { baseUrl: 'http://local.invalid' } + }, + ensureRegistry: async connectionId => { + calls.push(`registry:${connectionId}`) + + return { baseUrl: 'http://remote.invalid' } + } + } + ) + + assert.deepEqual(calls, ['legacy:coder']) + assert.equal(route.connectionId, null) + assert.equal(route.profile, 'coder') + assert.deepEqual(route.connection, { baseUrl: 'http://local.invalid' }) +}) diff --git a/apps/desktop/electron/gateway-file-download.ts b/apps/desktop/electron/gateway-file-download.ts index acdca9723c..09a362fefe 100644 --- a/apps/desktop/electron/gateway-file-download.ts +++ b/apps/desktop/electron/gateway-file-download.ts @@ -35,6 +35,34 @@ export interface PumpDeps { unlink: (destPath: string) => Promise } +export interface GatewayFileBackendDeps { + ensureLegacy: (profile: null | string) => Promise + ensureRegistry: (connectionId: string, profile: null | string) => Promise +} + +export interface GatewayFileBackendRoute { + connection: T + connectionId: null | string + profile: null | string +} + +/** + * Resolve the backend that owns a renderer-requested gateway file. Registered + * connections must never fall through to the legacy profile pool: that pool + * can point at another machine with another authentication credential. + */ +export async function resolveGatewayFileBackend( + payload: { connectionId?: unknown; profile?: unknown }, + deps: GatewayFileBackendDeps +): Promise> { + const connectionId = String(payload.connectionId ?? '').trim() || null + const profile = String(payload.profile ?? '').trim() || null + + const connection = connectionId ? await deps.ensureRegistry(connectionId, profile) : await deps.ensureLegacy(profile) + + return { connection, connectionId, profile } +} + // Stream `res` into `destPath`, honoring backpressure. On any read/write error // the write stream is torn down and the (partial) destination file is removed // before the returned promise rejects, so a failed download never leaves a diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 9da7bf7307..bf96d23515 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -87,11 +87,12 @@ import { normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, + pathForRegistryBackendRequest, pathWithGlobalRemoteProfile, - pathWithProfileScope, profileHasRemoteConnection, profileRemoteOverride, profileSshOverride, + type RegistryBackendRequestScope, remoteRequestMatchesBaseUrl, resolveAuthMode, resolveProfileApiRequest, @@ -100,7 +101,6 @@ import { resolveTestWsUrl, savedProfileSsh, tokenPreview, - translateSelfProfileQuery, withTransientRetries } from './connection-config' import { applyConnectionConfigAtomically } from './connection-config-apply' @@ -166,7 +166,8 @@ import { gatewayFilePath, isNotFoundError, parseDataUrlToBuffer, - pumpStreamToFile + pumpStreamToFile, + resolveGatewayFileBackend } from './gateway-file-download' import { probeGatewayWebSocket } from './gateway-ws-probe' import { registerGitIpc } from './git-ipc' @@ -1292,7 +1293,8 @@ function registerMediaProtocol() { return resolvedPath }, - resolveRemoteConnection: profile => ensureBackend(profile) + resolveRemoteConnection: ({ connectionId, profile }) => + connectionId ? ensureRegistryBackend(connectionId, profile) : ensureBackend(profile) }) protocol.handle(MEDIA_PROTOCOL, handler) @@ -7517,30 +7519,63 @@ function readGatewayErrorText(res): Promise { }) } -async function gatedFileAuth(connection) { +interface GatewayFileConnection extends RegistryBackendRequestScope { + authMode?: 'oauth' | 'token' + baseUrl: string + token?: null | string +} + +interface GatewayFileSaveContext { + fallbackName: string + suggested: string +} + +interface GatewayFileSavePayload { + connectionId?: unknown + path?: unknown + profile?: unknown + suggestedName?: unknown +} + +async function gatedFileAuth(connection: GatewayFileConnection) { const nativeAt = connection.authMode === 'oauth' ? await ensureNativeAccessToken(connection.baseUrl).catch(() => null) : null return resolveGatedDownloadAuth(connection.authMode, nativeAt, connection.token) } -async function saveGatewayFile(payload: any = {}) { +function gatewayFileRequestPath( + connection: GatewayFileConnection, + connectionId: null | string, + profile: null | string, + requestPath: string +) { + return connectionId + ? pathForRegistryBackendRequest(requestPath, profile, connection) + : pathWithGlobalRemoteProfile(requestPath, profile, profileRouteOptions(profile)) +} + +async function saveGatewayFile(payload: GatewayFileSavePayload = {}) { const filePath = gatewayFilePath(payload.path) if (!filePath) { throw new Error('Missing gateway file path') } - const profile = payload.profile || null - const connection = await ensureBackend(profile) + const { connection, connectionId, profile } = await resolveGatewayFileBackend(payload, { + ensureLegacy: ensureBackend, + ensureRegistry: ensureRegistryBackend + }) + const suggested = String(payload.suggestedName || '').trim() const fallbackName = path.basename(filePath) || suggested || 'download' const ctx = { suggested, fallbackName } - const requestPath = pathWithGlobalRemoteProfile( - `/api/fs/download?path=${encodeURIComponent(filePath)}`, + const requestPath = gatewayFileRequestPath( + connection, + connectionId, profile, - profileRouteOptions(profile) + `/api/fs/download?path=${encodeURIComponent(filePath)}` ) const url = `${connection.baseUrl}${requestPath}` @@ -7562,7 +7597,7 @@ async function saveGatewayFile(payload: any = {}) { // /api/fs/download 404s here; fall back (ONLY on 404) to the older capped // data-URL route so downloads keep working against older backends. if (isNotFoundError(error)) { - return await saveGatewayFileViaDataUrl(connection, profile, filePath, ctx) + return await saveGatewayFileViaDataUrl(connection, connectionId, profile, filePath, ctx) } throw error @@ -7573,16 +7608,23 @@ async function saveGatewayFile(payload: any = {}) { // `/api/fs/read-data-url` route, decode it, and save. Bounded by the gateway's // data-URL cap, so it only serves smaller files — enough to keep older gateways // working until they gain the streaming route. -async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any = {}) { - const requestPath = pathWithGlobalRemoteProfile( - `/api/fs/read-data-url?path=${encodeURIComponent(filePath)}`, +async function saveGatewayFileViaDataUrl( + connection: GatewayFileConnection, + connectionId: null | string, + profile: null | string, + filePath: string, + ctx: GatewayFileSaveContext +) { + const requestPath = gatewayFileRequestPath( + connection, + connectionId, profile, - profileRouteOptions(profile) + `/api/fs/read-data-url?path=${encodeURIComponent(filePath)}` ) const url = `${connection.baseUrl}${requestPath}` const auth = await gatedFileAuth(connection) - let json: any + let json: unknown if (auth.kind === 'bearer') { json = await fetchJson(url, null, { bearer: auth.token }) @@ -7592,7 +7634,8 @@ async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any json = await fetchJson(url, auth.token) } - const dataUrl = json?.dataUrl + const dataUrl = + json && typeof json === 'object' && 'dataUrl' in json && typeof json.dataUrl === 'string' ? json.dataUrl : '' if (!dataUrl) { throw new Error('Gateway returned no file data') @@ -13865,9 +13908,7 @@ async function dispatchRegistryApiRequest( ) { const connection: any = await ensureRegistryBackend(registryConnectionId, routeProfile) - const requestPath = connection.sharedRemote - ? pathWithProfileScope(request.path, requestProfile) - : translateSelfProfileQuery(request.path, requestProfile, connection.remoteProfile) + const requestPath = pathForRegistryBackendRequest(request.path, requestProfile, connection) return fetchJsonForBackend(connection, requestPath, { method: request?.method, diff --git a/apps/desktop/electron/media-protocol.test.ts b/apps/desktop/electron/media-protocol.test.ts index 02d6e7bfa0..66d2e8e384 100644 --- a/apps/desktop/electron/media-protocol.test.ts +++ b/apps/desktop/electron/media-protocol.test.ts @@ -15,7 +15,7 @@ function dependencies(overrides: Partial = {}) { fetchRemote: vi.fn(async (_url: string, _headers: Headers) => new Response('remote', { status: 206 })), fetchRemoteWithCookies: vi.fn(async (_url: string, _headers: Headers) => new Response('cookie', { status: 206 })), resolveLocalFile: vi.fn(async (filePath: string) => filePath), - resolveRemoteConnection: vi.fn(async (_profile?: string) => ({ + resolveRemoteConnection: vi.fn(async (_scope: { connectionId?: string; profile?: string }) => ({ authMode: 'token' as const, baseUrl: 'https://gateway.test', mode: 'remote' as const, @@ -105,13 +105,13 @@ describe('createMediaProtocolHandler', () => { }) const response = await createMediaProtocolHandler(deps)( - request('hermes-media://remote/%2Froot%2Foutputs%2Frender.mp4?profile=reviewer', { + request('hermes-media://remote/%2Froot%2Foutputs%2Frender.mp4?connectionId=work-ssh&profile=reviewer', { Range: 'bytes=0-1023' }) ) expect(response.status).toBe(206) - expect(deps.resolveRemoteConnection).toHaveBeenCalledWith('reviewer') + expect(deps.resolveRemoteConnection).toHaveBeenCalledWith({ connectionId: 'work-ssh', profile: 'reviewer' }) expect(deps.fetchRemote).toHaveBeenCalledOnce() const [rawUrl, headers] = vi.mocked(deps.fetchRemote).mock.calls[0] const url = new URL(rawUrl) @@ -122,6 +122,28 @@ describe('createMediaProtocolHandler', () => { expect(headers.get('range')).toBe('bytes=0-1023') }) + it('adds profile scope when one registry backend serves multiple profiles', async () => { + const deps = dependencies({ + resolveRemoteConnection: vi.fn(async () => ({ + authMode: 'token' as const, + baseUrl: 'https://gateway.test', + mode: 'remote' as const, + sharedRemote: true, + token: 'secret' + })) + }) + + await createMediaProtocolHandler(deps)( + request('hermes-media://remote/%2Froot%2Foutputs%2Frender.mp4?connectionId=cloud&profile=research') + ) + + const [rawUrl] = vi.mocked(deps.fetchRemote).mock.calls[0] + const url = new URL(rawUrl) + + expect(url.searchParams.get('path')).toBe('/root/outputs/render.mp4') + expect(url.searchParams.get('profile')).toBe('research') + }) + it('preserves explicit HEAD requests through the token-auth remote proxy', async () => { const fetchRemote = vi.fn(async (..._args: unknown[]) => new Response(null, { status: 200 })) diff --git a/apps/desktop/electron/media-protocol.ts b/apps/desktop/electron/media-protocol.ts index 1ec7d8617b..2e3cc94615 100644 --- a/apps/desktop/electron/media-protocol.ts +++ b/apps/desktop/electron/media-protocol.ts @@ -19,16 +19,23 @@ export const MEDIA_PROTOCOL = 'hermes-media' type MediaProtocolMode = 'remote' | 'stream' interface MediaProtocolTarget { + connectionId?: string filePath: string mode: MediaProtocolMode profile?: string } +export interface MediaRemoteScope { + connectionId?: string + profile?: string +} + export interface MediaRemoteConnection { authMode?: 'oauth' | 'token' baseUrl: string mode?: 'local' | 'remote' token?: null | string + sharedRemote?: boolean } type MediaRequestMethod = 'GET' | 'HEAD' @@ -39,7 +46,7 @@ export interface MediaProtocolDependencies { fetchRemote: (url: string, headers: Headers, method: MediaRequestMethod) => Promise fetchRemoteWithCookies: (url: string, headers: Headers, method: MediaRequestMethod) => Promise resolveLocalFile: (filePath: string) => Promise - resolveRemoteConnection: (profile?: string) => Promise + resolveRemoteConnection: (scope: MediaRemoteScope) => Promise } function parseMediaProtocolTarget(rawUrl: string): MediaProtocolTarget { @@ -56,9 +63,10 @@ function parseMediaProtocolTarget(rawUrl: string): MediaProtocolTarget { throw new Error('Missing media path') } + const connectionId = url.searchParams.get('connectionId')?.trim() || undefined const profile = url.searchParams.get('profile')?.trim() || undefined - return { filePath, mode, profile } + return { connectionId, filePath, mode, profile } } export function isStreamableMediaPath(filePath: string): boolean { @@ -81,7 +89,7 @@ export function mediaRequestHeaders(source: Headers): Headers { return forwarded } -export function remoteMediaEndpoint(baseUrl: string, filePath: string): string { +export function remoteMediaEndpoint(baseUrl: string, filePath: string, profile?: string): string { const normalizedBase = baseUrl.replace(/\/+$/, '') const url = new URL(`${normalizedBase}/api/files/stream`) @@ -91,6 +99,10 @@ export function remoteMediaEndpoint(baseUrl: string, filePath: string): string { url.searchParams.set('path', filePath) + if (profile) { + url.searchParams.set('profile', profile) + } + return url.toString() } @@ -133,13 +145,20 @@ export function createMediaProtocolHandler(dependencies: MediaProtocolDependenci } try { - const connection = await dependencies.resolveRemoteConnection(target.profile) + const connection = await dependencies.resolveRemoteConnection({ + connectionId: target.connectionId, + profile: target.profile + }) if (connection.mode !== 'remote') { return new Response('Remote media backend unavailable', { status: 404 }) } - const endpoint = remoteMediaEndpoint(connection.baseUrl, target.filePath) + const endpoint = remoteMediaEndpoint( + connection.baseUrl, + target.filePath, + connection.sharedRemote ? target.profile : undefined + ) if (connection.authMode === 'oauth') { const bearer = await dependencies.ensureRemoteBearer(connection.baseUrl) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index efab4dea93..65ba0dafeb 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -221,7 +221,12 @@ declare global { }) => Promise writeClipboard: (text: string) => Promise readClipboard: () => Promise - saveGatewayFile?: (payload: { path: string; profile?: null | string; suggestedName?: string }) => Promise<{ + saveGatewayFile?: (payload: { + connectionId?: null | string + path: string + profile?: null | string + suggestedName?: string + }) => Promise<{ canceled?: boolean path?: string saved: boolean diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index 274e5b0440..53698cae69 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setApiRequestConnection } from '@/api/client' import { $connection } from '@/store/session' import { @@ -66,12 +67,14 @@ describe('desktop filesystem facade', () => { beforeEach(() => { stubBridge() $connection.set(null) + setApiRequestConnection(null) }) afterEach(() => { vi.unstubAllGlobals() vi.clearAllMocks() $connection.set(null) + setApiRequestConnection(null) setDesktopFsRemotePicker(null) }) @@ -144,6 +147,24 @@ describe('desktop filesystem facade', () => { expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd', profile: 'remote-docker' }) }) + it('pins SSH filesystem reads to the active registry connection', async () => { + $connection.set({ + connectionId: 'work-ssh', + mode: 'remote', + profile: 'default', + remoteKind: 'ssh' + } as never) + setApiRequestConnection('work-ssh') + + await readDesktopFileDataUrl('/srv/project/image.png') + + expect(api).toHaveBeenCalledWith({ + connectionId: 'work-ssh', + path: '/api/fs/read-data-url?path=%2Fsrv%2Fproject%2Fimage.png', + profile: 'default' + }) + }) + it('keys SSH filesystem caches by stable host identity instead of the forwarded port', () => { $connection.set({ mode: 'remote', diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index 6123197c68..65ba0fcab4 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -1,3 +1,4 @@ +import { hermesApi } from '@/api/client' import type { HermesConnection, HermesReadDirResult, @@ -58,7 +59,7 @@ function bridge() { } function remoteFsApi(path: string, body?: Record): Promise { - return bridge().api( + return hermesApi( body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() } ) } diff --git a/apps/desktop/src/lib/media.remote.test.ts b/apps/desktop/src/lib/media.remote.test.ts index 3600172b35..fbfb5b1353 100644 --- a/apps/desktop/src/lib/media.remote.test.ts +++ b/apps/desktop/src/lib/media.remote.test.ts @@ -91,6 +91,19 @@ describe('mediaGatewayStreamUrl', () => { $connection.set({ authMode: 'oauth', mode: 'remote', profile: 'voice reviewer', token: null } as never) expect(mediaGatewayStreamUrl('/tmp/a.mp4')).toBe('hermes-media://remote/%2Ftmp%2Fa.mp4?profile=voice%20reviewer') }) + + it('pins remote streams to their registered connection and profile', () => { + $connection.set({ + connectionId: 'studio-ssh', + mode: 'remote', + profile: 'voice reviewer', + remoteKind: 'ssh' + } as never) + + expect(mediaGatewayStreamUrl('/tmp/a.mp4')).toBe( + 'hermes-media://remote/%2Ftmp%2Fa.mp4?connectionId=studio-ssh&profile=voice%20reviewer' + ) + }) }) describe('resolveMediaDisplaySrc', () => { @@ -225,7 +238,7 @@ describe('downloadGatewayMediaFile', () => { beforeEach(() => { saveGatewayFile.mockClear() vi.stubGlobal('window', { hermesDesktop: { saveGatewayFile } }) - $connection.set({ mode: 'remote', profile: 'docker-gw' } as never) + $connection.set({ connectionId: 'work-ssh', mode: 'remote', profile: 'docker-gw' } as never) }) afterEach(() => { @@ -240,6 +253,7 @@ describe('downloadGatewayMediaFile', () => { }) expect(saveGatewayFile).toHaveBeenCalledWith({ + connectionId: 'work-ssh', path: '/Users/me/project/a b.md', profile: 'docker-gw', suggestedName: 'a b.md' diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index a7952a0e11..c5d7824262 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -139,9 +139,15 @@ export function mediaGatewayStreamUrl(path: string): string { if (isRemoteGateway()) { const file = encodeURIComponent(filePathFromMediaPath(path)) - const profile = conn?.profile ? `?profile=${encodeURIComponent(conn.profile)}` : '' - return `hermes-media://remote/${file}${profile}` + const scope = [ + conn?.connectionId ? `connectionId=${encodeURIComponent(conn.connectionId)}` : '', + conn?.profile ? `profile=${encodeURIComponent(conn.profile)}` : '' + ] + .filter(Boolean) + .join('&') + + return `hermes-media://remote/${file}${scope ? `?${scope}` : ''}` } return mediaExternalUrl(path) @@ -208,6 +214,7 @@ export async function downloadGatewayMediaFile( } return window.hermesDesktop.saveGatewayFile({ + connectionId: conn?.connectionId, path: file, profile: conn?.profile, suggestedName: mediaName(file)