Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ export function ClientHostedBrowserPagePane({
runtimeEnvironmentId={runtimeEnvironmentId}
worktreeId={worktreeId}
lastCommittedUrl={browserTab.url}
placementHostClientId={browserHostClientId}
/>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status'
import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture'

const mocks = vi.hoisted(() => {
const state = {
runtimeStatusByEnvironmentId: new Map<string, RuntimeEnvironmentStatus>(),
runtimeEnvironments: [{ id: 'env-a', name: 'Studio PC' }],
remoteBrowserPageHandlesByPageId: {}
}
const useAppStore = <T,>(selector: (s: typeof state) => T): T => selector(state)
useAppStore.getState = () => state
return { state, useAppStore, ensure: vi.fn(), ownHostClientId: 'desktop-self' }
})

vi.mock('@/store', () => ({ useAppStore: mocks.useAppStore }))
vi.mock('@/runtime/restored-client-hosted-browser-host-attach', () => ({
ensureBrowserClientHostOnRuntimeContact: mocks.ensure
}))
vi.mock('@/runtime/browser-client-host-identity', () => ({
readBrowserClientHostId: () => mocks.ownHostClientId
}))
vi.mock('./browser-reopen-on-server', () => ({ reopenBrowserPageOnServer: vi.fn() }))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))

import { ClientHostedBrowserUnavailableNotice } from './client-hosted-browser-unavailable-notice'

afterEach(() => {
cleanup()
vi.clearAllMocks()
})

function renderNotice(
entry: RuntimeEnvironmentStatus | undefined,
placementHostClientId: string | null = 'desktop-self'
): void {
mocks.state.runtimeStatusByEnvironmentId = new Map(entry ? [['env-a', entry]] : [])
render(
<ClientHostedBrowserUnavailableNotice
runtimeEnvironmentId="env-a"
worktreeId="worktree-a"
lastCommittedUrl="https://example.internal/"
placementHostClientId={placementHostClientId}
/>
)
}

const liveResponse = createCompatibleRuntimeStatusResponse('rt-1')
const live: RuntimeEnvironmentStatus = {
status: liveResponse.ok ? liveResponse.result : null,
checkedAt: 1
}

describe('ClientHostedBrowserUnavailableNotice', () => {
it('says the host is offline while contact is lost', () => {
renderNotice({ status: null, checkedAt: 1 })

expect(
screen.getByText('Studio PC is offline. This page will reload here once it reconnects.')
).toBeInTheDocument()
expect(mocks.ensure).not.toHaveBeenCalled()
})

it('does not promise a reconnect for a retired pairing', () => {
renderNotice({
status: null,
checkedAt: 1,
snapshot: {
environmentId: 'env-a',
pairingRevision: 1,
sequence: 1,
checkedAt: 1,
transport: 'disconnected',
verification: 'unavailable',
status: null,
retired: true
}
})

expect(
screen.getByText("This page isn't available on this desktop right now.")
).toBeInTheDocument()
})

it('names another desktop only when the placement belongs to one', () => {
renderNotice(live, 'desktop-other')

expect(screen.getByText('This page is open on another desktop.')).toBeInTheDocument()
expect(mocks.ensure).not.toHaveBeenCalled()
})

// Why: a lease reconnect that fails after contact already returned has no later edge to re-host.
it('shows the generic copy and re-claims hosting once while the host is reachable', () => {
renderNotice(live)

expect(
screen.getByText("This page isn't available on this desktop right now.")
).toBeInTheDocument()
expect(mocks.ensure).toHaveBeenCalledTimes(1)
expect(mocks.ensure).toHaveBeenCalledWith(expect.anything(), 'env-a')
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { useEffect } from 'react'
import { Globe } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { readBrowserClientHostId } from '@/runtime/browser-client-host-identity'
import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state'
import { ensureBrowserClientHostOnRuntimeContact } from '@/runtime/restored-client-hosted-browser-host-attach'
import {
isRuntimeHostContactRevokedVerdict,
runtimeHostContactForEntry
} from '../../../../shared/runtime-host-contact'
import {
ReopenBrowserPageOnServerButton,
reopenOnServerCaveat
Expand All @@ -13,25 +22,62 @@ import {
export function ClientHostedBrowserUnavailableNotice({
runtimeEnvironmentId,
worktreeId,
lastCommittedUrl
lastCommittedUrl,
placementHostClientId
}: {
runtimeEnvironmentId: string
worktreeId: string
lastCommittedUrl: string
placementHostClientId: string | null
}): React.JSX.Element {
const offlineHostName = useAppStore((s) => {
const entry = s.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId)
const state = runtimeHostConnectionStateForEntry(entry)
// Why the revoked check: a retired or refused pairing also reads 'disconnected', but never
// reconnects.
const offline =
(state === 'reconnecting' || state === 'disconnected') &&
!isRuntimeHostContactRevokedVerdict(runtimeHostContactForEntry(entry))
return offline
? (s.runtimeEnvironments.find((environment) => environment.id === runtimeEnvironmentId)
?.name ?? null)
: null
})
const ownHostClientId = readBrowserClientHostId()
const onOtherDesktop = Boolean(
placementHostClientId && ownHostClientId && placementHostClientId !== ownHostClientId
)
// Why on mount: a lease reconnect that fails after the control link already came back leaves
// this desktop unhosted with no later contact edge to re-claim it.
useEffect(() => {
const state = useAppStore.getState()
if (!onOtherDesktop && state.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId)?.status) {
void ensureBrowserClientHostOnRuntimeContact(state, runtimeEnvironmentId)
}
}, [onOtherDesktop, runtimeEnvironmentId])
const description = onOtherDesktop
? translate(
'browser.clientHosted.unavailableOtherDesktopDescription',
'This page is open on another desktop.'
)
: offlineHostName
? translate(
'browser.clientHosted.unavailableHostOfflineDescription',
'{{host}} is offline. This page will reload here once it reconnects.',
{ host: offlineHostName }
)
: translate(
'browser.clientHosted.unavailableDescription',
"This page isn't available on this desktop right now."
)
return (
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
<div className="flex max-w-sm flex-col items-center gap-2">
<Globe className="size-5 text-muted-foreground" />
<div className="text-sm font-medium text-foreground">
{translate('browser.clientHosted.unavailableTitle', 'Client-hosted browser unavailable')}
</div>
<div className="text-xs leading-5 text-muted-foreground">
{translate(
'browser.clientHosted.unavailableDescription',
'This page is attached to a different desktop or is no longer available.'
)}
</div>
<div className="text-xs leading-5 text-muted-foreground">{description}</div>
<div className="text-xs leading-5 text-muted-foreground">{reopenOnServerCaveat()}</div>
<ReopenBrowserPageOnServerButton
environmentId={runtimeEnvironmentId}
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@
"clientHosted": {
"preparationFailed": "Couldn't start the remote browser on this desktop. Check the paired connection and try again.",
"unavailableTitle": "Client-hosted browser unavailable",
"unavailableDescription": "This page is attached to a different desktop or is no longer available.",
"unavailableDescription": "This page isn't available on this desktop right now.",
"unavailableOtherDesktopDescription": "This page is open on another desktop.",
"unavailableHostOfflineDescription": "{{host}} is offline. This page will reload here once it reconnects.",
"download": {
"started": "Downloading {{filename}}…",
"completed": "Downloaded {{filename}}",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -18606,7 +18606,9 @@
"clientHosted": {
"preparationFailed": "No se pudo iniciar el navegador remoto en este escritorio. Comprueba la conexión emparejada y vuelve a intentarlo.",
"unavailableTitle": "Navegador alojado en el cliente no disponible",
"unavailableDescription": "Esta página está asociada a otro escritorio o ya no está disponible.",
"unavailableDescription": "Esta página no está disponible en este escritorio en este momento.",
"unavailableOtherDesktopDescription": "Esta página está abierta en otro escritorio.",
"unavailableHostOfflineDescription": "{{host}} está sin conexión. Esta página se volverá a cargar aquí cuando se reconecte.",
"download": {
"started": "Descargando {{filename}}…",
"completed": "{{filename}} descargado",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"clientHosted": {
"preparationFailed": "Impossible de démarrer le navigateur distant sur ce bureau. Vérifiez la connexion couplée et réessayez.",
"unavailableTitle": "Navigateur hébergé par le client indisponible",
"unavailableDescription": "Cette page est attachée à un autre bureau ou n'est plus disponible.",
"unavailableDescription": "Cette page n'est pas disponible sur ce bureau pour le moment.",
"unavailableOtherDesktopDescription": "Cette page est ouverte sur un autre bureau.",
"unavailableHostOfflineDescription": "{{host}} est hors ligne. Cette page se rechargera ici dès sa reconnexion.",
"download": {
"started": "Téléchargement de {{filename}}…",
"completed": "Téléchargé {{filename}}",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -18606,7 +18606,9 @@
"clientHosted": {
"preparationFailed": "このデスクトップ上でリモートブラウザを起動できませんでした。ペアリングされた接続を確認して、もう一度試してください。",
"unavailableTitle": "クライアントホスト型ブラウザは利用不可",
"unavailableDescription": "このページは別のデスクトップに接続されているか、もう利用できません。",
"unavailableDescription": "このページは現在このデスクトップでは利用できません。",
"unavailableOtherDesktopDescription": "このページは別のデスクトップで開かれています。",
"unavailableHostOfflineDescription": "{{host}} はオフラインです。再接続されると、このページはここで再読み込みされます。",
"download": {
"started": "{{filename}}をダウンロード中…",
"completed": "{{filename}}をダウンロードしました",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -18606,7 +18606,9 @@
"clientHosted": {
"preparationFailed": "이 데스크톱에서 원격 브라우저를 시작할 수 없습니다. 페어링된 연결을 확인하고 다시 시도하세요.",
"unavailableTitle": "클라이언트 호스팅 브라우저를 사용할 수 없음",
"unavailableDescription": "이 페이지는 다른 데스크탑에 연결되어 있거나 더 이상 사용할 수 없습니다.",
"unavailableDescription": "이 페이지는 현재 이 데스크탑에서 사용할 수 없습니다.",
"unavailableOtherDesktopDescription": "이 페이지는 다른 데스크탑에서 열려 있습니다.",
"unavailableHostOfflineDescription": "{{host}}이(가) 오프라인입니다. 다시 연결되면 이 페이지가 여기에서 다시 로드됩니다.",
"download": {
"started": "{{filename}} 다운로드 중…",
"completed": "{{filename}}을(를) 다운로드했습니다.",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -18606,7 +18606,9 @@
"clientHosted": {
"preparationFailed": "无法在此桌面上启动远程浏览器。检查配对连接并重试。",
"unavailableTitle": "客户端托管的浏览器不可用",
"unavailableDescription": "此页面已附加到其他桌面或不再可用。",
"unavailableDescription": "此页面目前在此桌面上不可用。",
"unavailableOtherDesktopDescription": "此页面已在另一台桌面上打开。",
"unavailableHostOfflineDescription": "{{host}} 已离线。重新连接后,此页面将在此处重新加载。",
"download": {
"started": "正在下载 {{filename}}...",
"completed": "已下载{{filename}}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,24 @@ export async function ensureBrowserClientHostsForRestoredPages(
}

/**
* Re-claims hosting duty after the runtime process restarted under a new id.
* Re-claims hosting duty when contact with a runtime is (re)established: after it restarted under a
* new id, or after an outage.
*
* The guests are still alive in this desktop's webviews, but the runtime that placed them is gone
* and the replacement knows nothing about them. Preparing again drives the host registry down its
* `replaceAuthority` path, which keeps the guests and re-attaches with a page inventory the new
* runtime can adopt. Without this nothing observes the id change and the rows are simply lost.
* After a restart the guests are still alive in this desktop's webviews, but the replacement runtime
* knows nothing about them. Preparing again drives the host registry down its `replaceAuthority`
* path, which keeps the guests and re-attaches with a page inventory the new runtime can adopt.
* After an outage past the lease grace the host was torn down with its guests; preparing starts a
* fresh one, and the runtime's recovery re-places the pages at their last URL. Without this nothing
* observes either edge and the rows are simply lost.
*/
export async function ensureBrowserClientHostForRestartedRuntime(
export async function ensureBrowserClientHostOnRuntimeContact(
state: RestoredBrowserHandleSource,
environmentId: string
): Promise<void> {
if (!hasLiveClientHostedPage(state, environmentId)) {
return
}
// Why re-prepare: an in-flight preparation began before this contact, so it may have failed.
await prepareBrowserClientHost(environmentId, true)
}

Expand Down
Loading
Loading