From 519a205c0af7a8b4410cd0be04c6ce5c09f494d1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 13:07:21 -0300 Subject: [PATCH 01/15] feat: load a web UI preview bundle into a workspace Adds a `rocketchat://ui-preview?host=&bundle=` deep link that serves a standalone Rocket.Chat web client build (Vite) in place of the server's own UI, while keeping the server's origin so login, cookies and SSO keep working. - Requires Developer Mode and an explicit confirmation, since the bundle runs with the user's session. - HTML navigations and `/bundle/*` come from the bundle; every other request is forwarded to the server with the session's cookies. - The override lives in memory only; View > Restore server UI or an app restart returns to the server's UI. Co-Authored-By: Claude Opus 5.5 --- src/deepLinks/main.ts | 43 +++++ src/i18n/en.i18n.json | 11 +- src/i18n/pt-BR.i18n.json | 11 +- src/ui/main/dialogs.ts | 33 ++++ src/ui/main/menuBar.ts | 12 ++ .../main/serverView/uiOverride.main.spec.ts | 55 ++++++ src/ui/main/serverView/uiOverride.ts | 168 ++++++++++++++++++ 7 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 src/ui/main/serverView/uiOverride.main.spec.ts create mode 100644 src/ui/main/serverView/uiOverride.ts diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index d695264c06..ddd08047c5 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -12,10 +12,13 @@ import { openTelephonyDialpad } from '../telephony/dialpad'; import { parseTelephonyLink } from '../telephony/links'; import { askForServerAddition, + askForUiOverride, warnAboutInvalidServerUrl, + warnAboutUiOverrideRequiresDeveloperMode, } from '../ui/main/dialogs'; import { getRootWindow } from '../ui/main/rootWindow'; import { getWebContentsByServerUrl } from '../ui/main/serverView'; +import { applyUiOverride } from '../ui/main/serverView/uiOverride'; import { DEEP_LINKS_SERVER_FOCUSED, DEEP_LINKS_SERVER_ADDED } from './actions'; export type { TelephonyLink } from '../telephony/common'; @@ -257,6 +260,37 @@ const performAuthDeepLink = async (args: URLSearchParams): Promise => { } }; +const performUiPreview = async ({ + host, + bundle, +}: { + host: string; + bundle: string; +}): Promise => { + if (!select(({ isDeveloperModeEnabled }) => isDeveloperModeEnabled)) { + await warnAboutUiOverrideRequiresDeveloperMode(); + return; + } + + let bundleUrl: URL; + try { + bundleUrl = new URL(bundle); + } catch { + return; + } + if (bundleUrl.protocol !== 'https:' && bundleUrl.protocol !== 'http:') { + return; + } + + await performOnServer(host, async (serverUrl) => { + if (!(await askForUiOverride(serverUrl, bundleUrl.href))) { + return; + } + await getWebContents(serverUrl); + await applyUiOverride(serverUrl, bundleUrl.href); + }); +}; + const processDeepLink = async (deepLink: string): Promise => { const telephonyLink = parseTelephonyLink(deepLink); if (telephonyLink) { @@ -304,6 +338,15 @@ const processDeepLink = async (deepLink: string): Promise => { break; } + case 'ui-preview': { + const host = args.get('host') ?? undefined; + const bundle = args.get('bundle') ?? undefined; + if (host && bundle) { + await performUiPreview({ host, bundle }); + } + break; + } + case 'conference': { const host = args.get('host') ?? undefined; const path = args.get('path') ?? undefined; diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index a323aafcb1..0e9a281d12 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -202,6 +202,14 @@ "message": "Remove the stored decision for \"{{- domain}}\"? The next visit will ask you again.", "yes": "Remove", "cancel": "Cancel" + }, + "uiOverride": { + "title": "Load UI preview", + "message": "Load a different web UI for \"{{- host}}\"?", + "detail": "The UI will be loaded from {{- bundle}} and will run with your session on this server. Only continue if you trust this build. Use View > Restore server UI or restart the app to go back.", + "load": "Load UI", + "cancel": "Cancel", + "developerModeRequired": "Enable Developer Mode in Settings to load UI previews." } }, "documentViewer": { @@ -555,7 +563,8 @@ "viewMenu": "&View", "windowMenu": "&Window", "zoomIn": "Zoom in", - "zoomOut": "Zoom out" + "zoomOut": "Zoom out", + "restoreServerUi": "Restore server UI" }, "loadingError": { "title": "Server Failed to Load", diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index 8103e2e90a..6763e5634c 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -148,6 +148,14 @@ }, "supportedVersion": { "title": "Versão de workspace não suportada" + }, + "uiOverride": { + "title": "Carregar prévia de UI", + "message": "Carregar outra interface web para \"{{- host}}\"?", + "detail": "A interface será carregada de {{- bundle}} e rodará com sua sessão neste servidor. Continue apenas se confiar neste build. Use Exibir > Restaurar UI do servidor ou reinicie o app para voltar.", + "load": "Carregar UI", + "cancel": "Cancelar", + "developerModeRequired": "Ative o Modo Desenvolvedor nas Configurações para carregar prévias de UI." } }, "documentViewer": { @@ -374,7 +382,8 @@ "windowMenu": "&Janela", "zoomIn": "Aumentar zoom", "zoomOut": "Diminuir zoom", - "simulateDisconnected": "Simular desconexão" + "simulateDisconnected": "Simular desconexão", + "restoreServerUi": "Restaurar UI do servidor" }, "loadingError": { "title": "Servidor Falhou em Carregar", diff --git a/src/ui/main/dialogs.ts b/src/ui/main/dialogs.ts index b36e4d1f0b..a06f3c2340 100644 --- a/src/ui/main/dialogs.ts +++ b/src/ui/main/dialogs.ts @@ -45,6 +45,39 @@ export const askForServerAddition = async ( return response === 0; }; +export const askForUiOverride = async ( + serverUrl: string, + bundleUrl: string, + parentWindow?: BrowserWindow +): Promise => { + parentWindow?.show(); + + const { response } = await dialog.showMessageBox( + parentWindow ?? (await getRootWindow()), + { + type: 'warning', + buttons: [t('dialog.uiOverride.load'), t('dialog.uiOverride.cancel')], + defaultId: 1, + cancelId: 1, + title: t('dialog.uiOverride.title'), + message: t('dialog.uiOverride.message', { host: serverUrl }), + detail: t('dialog.uiOverride.detail', { bundle: bundleUrl }), + } + ); + + return response === 0; +}; + +export const warnAboutUiOverrideRequiresDeveloperMode = async ( + parentWindow?: BrowserWindow +): Promise => { + await dialog.showMessageBox(parentWindow ?? (await getRootWindow()), { + type: 'info', + title: t('dialog.uiOverride.title'), + message: t('dialog.uiOverride.developerModeRequired'), + }); +}; + export const warnAboutInvalidServerUrl = ( _serverUrl: string, _reason: string, diff --git a/src/ui/main/menuBar.ts b/src/ui/main/menuBar.ts index 389f3afe5c..769f54184d 100644 --- a/src/ui/main/menuBar.ts +++ b/src/ui/main/menuBar.ts @@ -47,6 +47,7 @@ import { formatServerTitle } from '../components/utils/formatServerTitle'; import { askForAppDataReset } from './dialogs'; import { getRootWindow } from './rootWindow'; import { getWebContentsByServerUrl } from './serverView'; +import { clearUiOverride } from './serverView/uiOverride'; const t = i18next.t.bind(i18next); @@ -406,6 +407,17 @@ export const createViewMenu = createSelector( }); }, }, + { + id: 'restoreServerUi', + label: t('menus.restoreServerUi'), + enabled: typeof currentView === 'object' && !!currentView.url, + click: async () => { + const currentView = await getCurrentView(); + if (typeof currentView === 'object' && !!currentView.url) { + await clearUiOverride(currentView.url); + } + }, + }, { type: 'separator' }, { id: 'back', diff --git a/src/ui/main/serverView/uiOverride.main.spec.ts b/src/ui/main/serverView/uiOverride.main.spec.ts new file mode 100644 index 0000000000..f9148463ec --- /dev/null +++ b/src/ui/main/serverView/uiOverride.main.spec.ts @@ -0,0 +1,55 @@ +import { isUiServedFromServer, prepareIndexHtml } from './uiOverride'; + +const viteIndex = + '
'; + +describe('prepareIndexHtml', () => { + it('injects the runtime config the Meteor server would provide', () => { + const html = prepareIndexHtml( + viteIndex, + 'https://open.rocket.chat', + 'https://builds.example/pr-1/' + ); + + expect(html).toContain( + '' + ); + expect(html).toContain(''); + expect(html).toContain('UI preview: https://builds.example/pr-1/'); + }); + + it('keeps a server path prefix in the base href and runtime config', () => { + const html = prepareIndexHtml( + viteIndex, + 'https://example.com/chat', + 'https://builds.example/pr-1/' + ); + + expect(html).toContain(''); + expect(html).toContain( + '"ROOT_URL":"https://example.com/chat/","ROOT_URL_PATH_PREFIX":"/chat"' + ); + }); + + it('escapes the bundle URL shown in the badge', () => { + const html = prepareIndexHtml( + viteIndex, + 'https://open.rocket.chat', + 'https://builds.example/">' + ); + + expect(html).not.toContain(''); + }); +}); + +describe('isUiServedFromServer', () => { + it('sends server routes to the server', () => { + expect(isUiServedFromServer('/api/v1/me', '')).toBe(true); + expect(isUiServedFromServer('/chat/avatar/user', '/chat')).toBe(true); + }); + + it('lets client routes load the preview UI', () => { + expect(isUiServedFromServer('/home', '')).toBe(false); + expect(isUiServedFromServer('/chat/channel/general', '/chat')).toBe(false); + }); +}); diff --git a/src/ui/main/serverView/uiOverride.ts b/src/ui/main/serverView/uiOverride.ts new file mode 100644 index 0000000000..4e778000b4 --- /dev/null +++ b/src/ui/main/serverView/uiOverride.ts @@ -0,0 +1,168 @@ +import type { Session } from 'electron'; +import { session } from 'electron'; + +import { getWebContentsByServerUrl } from '.'; + +// Paths the Rocket.Chat server answers itself; mirrors `serverRoutes` in apps/meteor/vite/vite.config.mts. +const serverRoutes = [ + '/api', + '/_oauth', + '/_saml', + '/_cas', + '/_accounts', + '/_timesync', + '/i18n', + '/avatar', + '/emoji-custom', + '/custom-sounds', + '/file-upload', + '/file-decrypt', + '/ufs', + '/data-export', + '/assets', + '/livechat', + '/theme.css', + '/robots.txt', + '/css-theme', + '/scripts_', + '/websocket', + '/sockjs', +]; + +// Vite `build.assetsDir` of the standalone client. +const bundleAssetsPath = '/bundle/'; + +// ponytail: in-memory only, so a restart always returns every server to its own UI. +const overrides = new Map(); + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (char) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + char + ] as string + ); + +const withTrailingSlash = (url: string) => + url.endsWith('/') ? url : `${url}/`; + +const getServerSession = (serverUrl: string) => + session.fromPartition(`persist:${serverUrl}`); + +const getScheme = (serverUrl: string) => + new URL(serverUrl).protocol.replace(':', ''); + +export const isUiServedFromServer = ( + pathname: string, + serverPathPrefix: string +) => { + const path = pathname.startsWith(serverPathPrefix) + ? pathname.slice(serverPathPrefix.length) || '/' + : pathname; + return serverRoutes.some((route) => path.startsWith(route)); +}; + +// The Meteor server injects the runtime config and base path into its HTML; the static bundle's index.html has neither. +export const prepareIndexHtml = ( + html: string, + serverUrl: string, + bundleUrl: string +) => { + const { origin, pathname } = new URL(withTrailingSlash(serverUrl)); + const runtimeConfig = JSON.stringify({ + ROOT_URL: origin + pathname, + ROOT_URL_PATH_PREFIX: pathname.replace(/\/$/, ''), + }); + const badge = `
UI preview: ${escapeHtml(bundleUrl)}
`; + + return html + .replace(//, ``) + .replace( + '', + `` + ) + .replace('', `${badge}`); +}; + +const passthrough = (ses: Session, request: Request) => + ses.fetch(request, { + bypassCustomProtocolHandlers: true, + credentials: 'include', + }); + +// ponytail: every request of the server's scheme in this session goes through the main process while an override is active; fine for testing, not for daily use. +const createHandler = + (ses: Session, serverUrl: string, bundleUrl: string) => + async (request: Request): Promise => { + const server = new URL(withTrailingSlash(serverUrl)); + const url = new URL(request.url); + + if (url.origin !== server.origin || request.method !== 'GET') { + return passthrough(ses, request); + } + + if (url.pathname.startsWith(bundleAssetsPath)) { + return ses.fetch(new URL(url.pathname.slice(1), bundleUrl).href, { + bypassCustomProtocolHandlers: true, + }); + } + + const isDocument = request.headers.get('accept')?.includes('text/html'); + if ( + !isDocument || + isUiServedFromServer(url.pathname, server.pathname.replace(/\/$/, '')) + ) { + return passthrough(ses, request); + } + + const index = await ses.fetch(new URL('index.html', bundleUrl).href, { + bypassCustomProtocolHandlers: true, + cache: 'no-store', + }); + if (!index.ok) { + return index; + } + + return new Response( + prepareIndexHtml(await index.text(), serverUrl, bundleUrl), + { headers: { 'content-type': 'text/html; charset=utf-8' } } + ); + }; + +const reloadServer = async (serverUrl: string, ses: Session) => { + // A cached Meteor service worker would otherwise keep serving the previous UI. + await ses.clearStorageData({ storages: ['serviceworkers', 'cachestorage'] }); + await ses.clearCache(); + getWebContentsByServerUrl(serverUrl)?.reloadIgnoringCache(); +}; + +export const hasUiOverride = (serverUrl: string) => overrides.has(serverUrl); + +export const applyUiOverride = async (serverUrl: string, bundleUrl: string) => { + const ses = getServerSession(serverUrl); + const scheme = getScheme(serverUrl); + const normalizedBundleUrl = withTrailingSlash(bundleUrl); + + if (ses.protocol.isProtocolHandled(scheme)) { + ses.protocol.unhandle(scheme); + } + ses.protocol.handle( + scheme, + createHandler(ses, serverUrl, normalizedBundleUrl) + ); + overrides.set(serverUrl, normalizedBundleUrl); + + await reloadServer(serverUrl, ses); +}; + +export const clearUiOverride = async (serverUrl: string) => { + if (!overrides.delete(serverUrl)) { + return; + } + + const ses = getServerSession(serverUrl); + ses.protocol.unhandle(getScheme(serverUrl)); + + await reloadServer(serverUrl, ses); +}; From b7fc0f8d1f6493b41e7f5bfbe4ac0d460027d1ff Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 13:33:25 -0300 Subject: [PATCH 02/15] feat: pull PR UI previews from ghcr.io `rocketchat://ui-preview?pr=[&sha=][&host=]` now pulls the bundle Rocket.Chat's CI publishes to ghcr.io/rocketchat/rocket.chat-ui-preview, using the registry's anonymous pull token, so testers need no GitHub login and no extra hosting. - The layer digest is verified before the ustar archive is extracted into userData/ui-previews/, which also serves as the cache; entries escaping that directory are refused and links are skipped. - The extracted bundle is served through the existing override via file://. - Without `host`, the preview applies to the workspace in focus. - A failed pull shows an error dialog. Co-Authored-By: Claude Opus 5.5 --- src/deepLinks/main.ts | 97 ++++++++++++---- src/i18n/en.i18n.json | 3 +- src/i18n/pt-BR.i18n.json | 3 +- src/ui/main/dialogs.ts | 12 ++ src/ui/main/serverView/uiOverride.ts | 16 ++- .../serverView/uiPreviewPackage.main.spec.ts | 63 +++++++++++ src/ui/main/serverView/uiPreviewPackage.ts | 106 ++++++++++++++++++ 7 files changed, 270 insertions(+), 30 deletions(-) create mode 100644 src/ui/main/serverView/uiPreviewPackage.main.spec.ts create mode 100644 src/ui/main/serverView/uiPreviewPackage.ts diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index ddd08047c5..8d3c7fa2a3 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -1,3 +1,5 @@ +import { pathToFileURL } from 'url'; + import type { WebContents } from 'electron'; import { app } from 'electron'; @@ -15,10 +17,15 @@ import { askForUiOverride, warnAboutInvalidServerUrl, warnAboutUiOverrideRequiresDeveloperMode, + warnAboutUiPreviewFailure, } from '../ui/main/dialogs'; import { getRootWindow } from '../ui/main/rootWindow'; import { getWebContentsByServerUrl } from '../ui/main/serverView'; import { applyUiOverride } from '../ui/main/serverView/uiOverride'; +import { + getUiPreviewReference, + pullUiPreview, +} from '../ui/main/serverView/uiPreviewPackage'; import { DEEP_LINKS_SERVER_FOCUSED, DEEP_LINKS_SERVER_ADDED } from './actions'; export type { TelephonyLink } from '../telephony/common'; @@ -260,35 +267,80 @@ const performAuthDeepLink = async (args: URLSearchParams): Promise => { } }; -const performUiPreview = async ({ - host, +type UiPreviewParams = { + host?: string; + bundle?: string; + pr?: string; + sha?: string; +}; + +const resolveUiPreviewSource = ({ bundle, -}: { - host: string; - bundle: string; -}): Promise => { - if (!select(({ isDeveloperModeEnabled }) => isDeveloperModeEnabled)) { - await warnAboutUiOverrideRequiresDeveloperMode(); - return; + pr, + sha, +}: UiPreviewParams): { label: string; load: () => Promise } | null => { + if (pr) { + if (!/^\d+$/.test(pr) || (sha && !/^[a-f0-9]{40}$/.test(sha))) { + return null; + } + const reference = getUiPreviewReference(sha ?? `pr-${pr}`); + return { + label: `PR #${pr} (${reference})`, + load: async () => + pathToFileURL(await pullUiPreview(sha ?? `pr-${pr}`)).href, + }; } - let bundleUrl: URL; + if (!bundle) { + return null; + } try { - bundleUrl = new URL(bundle); + const { protocol, href } = new URL(bundle); + return protocol === 'https:' || protocol === 'http:' + ? { label: href, load: async () => href } + : null; } catch { + return null; + } +}; + +const performUiPreview = async (params: UiPreviewParams): Promise => { + if (!select(({ isDeveloperModeEnabled }) => isDeveloperModeEnabled)) { + await warnAboutUiOverrideRequiresDeveloperMode(); return; } - if (bundleUrl.protocol !== 'https:' && bundleUrl.protocol !== 'http:') { + + const source = resolveUiPreviewSource(params); + if (!source) { return; } - await performOnServer(host, async (serverUrl) => { - if (!(await askForUiOverride(serverUrl, bundleUrl.href))) { + const applyToServer = async (serverUrl: string): Promise => { + if (!(await askForUiOverride(serverUrl, source.label))) { return; } - await getWebContents(serverUrl); - await applyUiOverride(serverUrl, bundleUrl.href); - }); + try { + const bundleUrl = await source.load(); + await getWebContents(serverUrl); + await applyUiOverride(serverUrl, bundleUrl, source.label); + } catch (error) { + await warnAboutUiPreviewFailure( + error instanceof Error ? error.message : String(error) + ); + } + }; + + if (params.host) { + await performOnServer(params.host, applyToServer); + return; + } + + const focusedServerUrl = select(({ currentView }) => + typeof currentView === 'object' ? currentView.url : undefined + ); + if (focusedServerUrl) { + await applyToServer(focusedServerUrl); + } }; const processDeepLink = async (deepLink: string): Promise => { @@ -339,11 +391,12 @@ const processDeepLink = async (deepLink: string): Promise => { } case 'ui-preview': { - const host = args.get('host') ?? undefined; - const bundle = args.get('bundle') ?? undefined; - if (host && bundle) { - await performUiPreview({ host, bundle }); - } + await performUiPreview({ + host: args.get('host') ?? undefined, + bundle: args.get('bundle') ?? undefined, + pr: args.get('pr') ?? undefined, + sha: args.get('sha') ?? undefined, + }); break; } diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 0e9a281d12..d3b6ac9cae 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -209,7 +209,8 @@ "detail": "The UI will be loaded from {{- bundle}} and will run with your session on this server. Only continue if you trust this build. Use View > Restore server UI or restart the app to go back.", "load": "Load UI", "cancel": "Cancel", - "developerModeRequired": "Enable Developer Mode in Settings to load UI previews." + "developerModeRequired": "Enable Developer Mode in Settings to load UI previews.", + "failed": "The UI preview could not be loaded." } }, "documentViewer": { diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index 6763e5634c..249da0862d 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -155,7 +155,8 @@ "detail": "A interface será carregada de {{- bundle}} e rodará com sua sessão neste servidor. Continue apenas se confiar neste build. Use Exibir > Restaurar UI do servidor ou reinicie o app para voltar.", "load": "Carregar UI", "cancel": "Cancelar", - "developerModeRequired": "Ative o Modo Desenvolvedor nas Configurações para carregar prévias de UI." + "developerModeRequired": "Ative o Modo Desenvolvedor nas Configurações para carregar prévias de UI.", + "failed": "Não foi possível carregar a prévia de UI." } }, "documentViewer": { diff --git a/src/ui/main/dialogs.ts b/src/ui/main/dialogs.ts index a06f3c2340..7b7600e380 100644 --- a/src/ui/main/dialogs.ts +++ b/src/ui/main/dialogs.ts @@ -78,6 +78,18 @@ export const warnAboutUiOverrideRequiresDeveloperMode = async ( }); }; +export const warnAboutUiPreviewFailure = async ( + reason: string, + parentWindow?: BrowserWindow +): Promise => { + await dialog.showMessageBox(parentWindow ?? (await getRootWindow()), { + type: 'error', + title: t('dialog.uiOverride.title'), + message: t('dialog.uiOverride.failed'), + detail: reason, + }); +}; + export const warnAboutInvalidServerUrl = ( _serverUrl: string, _reason: string, diff --git a/src/ui/main/serverView/uiOverride.ts b/src/ui/main/serverView/uiOverride.ts index 4e778000b4..c9aabfe8cb 100644 --- a/src/ui/main/serverView/uiOverride.ts +++ b/src/ui/main/serverView/uiOverride.ts @@ -67,14 +67,14 @@ export const isUiServedFromServer = ( export const prepareIndexHtml = ( html: string, serverUrl: string, - bundleUrl: string + label: string ) => { const { origin, pathname } = new URL(withTrailingSlash(serverUrl)); const runtimeConfig = JSON.stringify({ ROOT_URL: origin + pathname, ROOT_URL_PATH_PREFIX: pathname.replace(/\/$/, ''), }); - const badge = `
UI preview: ${escapeHtml(bundleUrl)}
`; + const badge = `
UI preview: ${escapeHtml(label)}
`; return html .replace(//, ``) @@ -93,7 +93,7 @@ const passthrough = (ses: Session, request: Request) => // ponytail: every request of the server's scheme in this session goes through the main process while an override is active; fine for testing, not for daily use. const createHandler = - (ses: Session, serverUrl: string, bundleUrl: string) => + (ses: Session, serverUrl: string, bundleUrl: string, label: string) => async (request: Request): Promise => { const server = new URL(withTrailingSlash(serverUrl)); const url = new URL(request.url); @@ -125,7 +125,7 @@ const createHandler = } return new Response( - prepareIndexHtml(await index.text(), serverUrl, bundleUrl), + prepareIndexHtml(await index.text(), serverUrl, label), { headers: { 'content-type': 'text/html; charset=utf-8' } } ); }; @@ -139,7 +139,11 @@ const reloadServer = async (serverUrl: string, ses: Session) => { export const hasUiOverride = (serverUrl: string) => overrides.has(serverUrl); -export const applyUiOverride = async (serverUrl: string, bundleUrl: string) => { +export const applyUiOverride = async ( + serverUrl: string, + bundleUrl: string, + label = bundleUrl +) => { const ses = getServerSession(serverUrl); const scheme = getScheme(serverUrl); const normalizedBundleUrl = withTrailingSlash(bundleUrl); @@ -149,7 +153,7 @@ export const applyUiOverride = async (serverUrl: string, bundleUrl: string) => { } ses.protocol.handle( scheme, - createHandler(ses, serverUrl, normalizedBundleUrl) + createHandler(ses, serverUrl, normalizedBundleUrl, label) ); overrides.set(serverUrl, normalizedBundleUrl); diff --git a/src/ui/main/serverView/uiPreviewPackage.main.spec.ts b/src/ui/main/serverView/uiPreviewPackage.main.spec.ts new file mode 100644 index 0000000000..712cceb286 --- /dev/null +++ b/src/ui/main/serverView/uiPreviewPackage.main.spec.ts @@ -0,0 +1,63 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { extractTar } from './uiPreviewPackage'; + +const ustarEntry = (name: string, data = '', type = '0') => { + const header = Buffer.alloc(512); + header.write(name, 0, 100, 'utf8'); + header.write(data.length.toString(8).padStart(11, '0'), 124, 12, 'utf8'); + header.write(type, 156, 1, 'utf8'); + header.write('ustar', 257, 6, 'utf8'); + const body = Buffer.alloc(Math.ceil(data.length / 512) * 512); + body.write(data, 0, 'utf8'); + return Buffer.concat([header, body]); +}; + +const tarOf = (...entries: Buffer[]) => + Buffer.concat([...entries, Buffer.alloc(1024)]); + +describe('extractTar', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-preview-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes files and directories of a ustar archive', () => { + extractTar( + tarOf( + ustarEntry('./', '', '5'), + ustarEntry('./index.html', ''), + ustarEntry('./bundle/', '', '5'), + ustarEntry('./bundle/index.js', 'x'.repeat(600)) + ), + dir + ); + + expect(fs.readFileSync(path.join(dir, 'index.html'), 'utf8')).toBe( + '' + ); + expect(fs.readFileSync(path.join(dir, 'bundle/index.js'), 'utf8')).toBe( + 'x'.repeat(600) + ); + }); + + it('refuses entries that escape the target directory', () => { + expect(() => + extractTar(tarOf(ustarEntry('../outside.js', 'x')), dir) + ).toThrow('Unsafe path'); + expect(fs.existsSync(path.join(dir, '..', 'outside.js'))).toBe(false); + }); + + it('skips links instead of following them', () => { + extractTar(tarOf(ustarEntry('./link', '', '2')), dir); + + expect(fs.existsSync(path.join(dir, 'link'))).toBe(false); + }); +}); diff --git a/src/ui/main/serverView/uiPreviewPackage.ts b/src/ui/main/serverView/uiPreviewPackage.ts new file mode 100644 index 0000000000..83329e1487 --- /dev/null +++ b/src/ui/main/serverView/uiPreviewPackage.ts @@ -0,0 +1,106 @@ +import { createHash } from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { gunzipSync } from 'zlib'; + +import { app, net } from 'electron'; + +// Published by Rocket.Chat's `UI Preview Publish` workflow; public, so pulls need no GitHub login. +const registry = 'https://ghcr.io'; +const repository = 'rocketchat/rocket.chat-ui-preview'; + +export const getUiPreviewReference = (tag: string) => + `ghcr.io/${repository}:${tag}`; + +// ponytail: ustar only (files and directories), which is what the workflow writes with `tar --format=ustar`. +export const extractTar = (tar: Buffer, targetDir: string): void => { + const root = path.resolve(targetDir); + fs.mkdirSync(root, { recursive: true }); + + for (let offset = 0; offset + 512 <= tar.length; ) { + const header = tar.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + + const field = (start: number, length: number) => + header + .subarray(start, start + length) + .toString('utf8') + .replace(/\0[\s\S]*$/, ''); + const name = field(0, 100); + const prefix = field(345, 155); + const size = parseInt(field(124, 12).trim() || '0', 8); + const type = field(156, 1) || '0'; + const dataStart = offset + 512; + offset = dataStart + Math.ceil(size / 512) * 512; + + const entryName = prefix ? `${prefix}/${name}` : name; + const target = path.resolve(root, entryName); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error(`Unsafe path in UI preview bundle: ${entryName}`); + } + + if (type === '5') { + fs.mkdirSync(target, { recursive: true }); + } else if (type === '0') { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, tar.subarray(dataStart, dataStart + size)); + } + } +}; + +const fetchOk = async (url: string, init?: RequestInit) => { + const response = await net.fetch(url, init); + if (!response.ok) { + throw new Error(`${url} responded ${response.status}`); + } + return response; +}; + +// Resolves to a local directory holding the bundle; each layer digest is extracted once and reused. +export const pullUiPreview = async (tag: string): Promise => { + const { token } = await ( + await fetchOk(`${registry}/token?scope=repository:${repository}:pull`) + ).json(); + const headers = { Authorization: `Bearer ${token}` }; + + const manifest = await ( + await fetchOk(`${registry}/v2/${repository}/manifests/${tag}`, { + headers: { + ...headers, + Accept: 'application/vnd.oci.image.manifest.v1+json', + }, + }) + ).json(); + + const digest: unknown = manifest.layers?.[0]?.digest; + if (typeof digest !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(digest)) { + throw new Error(`${getUiPreviewReference(tag)} has no bundle layer`); + } + const hash = digest.slice('sha256:'.length); + + const dir = path.join(app.getPath('userData'), 'ui-previews', hash); + if (fs.existsSync(path.join(dir, 'index.html'))) { + return dir; + } + + const blob = Buffer.from( + await ( + await fetchOk(`${registry}/v2/${repository}/blobs/${digest}`, { + headers, + }) + ).arrayBuffer() + ); + if (createHash('sha256').update(blob).digest('hex') !== hash) { + throw new Error(`${getUiPreviewReference(tag)} failed digest check`); + } + + const partialDir = `${dir}.partial`; + fs.rmSync(partialDir, { recursive: true, force: true }); + extractTar(gunzipSync(blob), partialDir); + fs.rmSync(dir, { recursive: true, force: true }); + fs.renameSync(partialDir, dir); + + return dir; +}; From 209dfe7a06a9db536bffbedd7ff5fbeaeed25f0c Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 14:21:47 -0300 Subject: [PATCH 03/15] feat: pull previews from rocket.chat-web-preview Co-Authored-By: Claude Opus 5.5 --- src/ui/main/serverView/uiPreviewPackage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/main/serverView/uiPreviewPackage.ts b/src/ui/main/serverView/uiPreviewPackage.ts index 83329e1487..2175d1dc4e 100644 --- a/src/ui/main/serverView/uiPreviewPackage.ts +++ b/src/ui/main/serverView/uiPreviewPackage.ts @@ -7,7 +7,7 @@ import { app, net } from 'electron'; // Published by Rocket.Chat's `UI Preview Publish` workflow; public, so pulls need no GitHub login. const registry = 'https://ghcr.io'; -const repository = 'rocketchat/rocket.chat-ui-preview'; +const repository = 'rocketchat/rocket.chat-web-preview'; export const getUiPreviewReference = (tag: string) => `ghcr.io/${repository}:${tag}`; From f65b75d02072ebcee7235f9aec9e5d8531a8b45b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 14:23:09 -0300 Subject: [PATCH 04/15] feat: pull previews from ghcr.io/rocketchat/rocket.chat-web Co-Authored-By: Claude Opus 5.5 --- src/ui/main/serverView/uiPreviewPackage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/main/serverView/uiPreviewPackage.ts b/src/ui/main/serverView/uiPreviewPackage.ts index 2175d1dc4e..24e1ae7500 100644 --- a/src/ui/main/serverView/uiPreviewPackage.ts +++ b/src/ui/main/serverView/uiPreviewPackage.ts @@ -7,7 +7,7 @@ import { app, net } from 'electron'; // Published by Rocket.Chat's `UI Preview Publish` workflow; public, so pulls need no GitHub login. const registry = 'https://ghcr.io'; -const repository = 'rocketchat/rocket.chat-web-preview'; +const repository = 'rocketchat/rocket.chat-web'; export const getUiPreviewReference = (tag: string) => `ghcr.io/${repository}:${tag}`; From 6b50d092a741844094222423f425ee4e4528ede2 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 22:30:26 -0300 Subject: [PATCH 05/15] chore: point the preview package comment at the UI Preview workflow Co-Authored-By: Claude Opus 5.5 --- src/ui/main/serverView/uiPreviewPackage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/main/serverView/uiPreviewPackage.ts b/src/ui/main/serverView/uiPreviewPackage.ts index 24e1ae7500..750ba0ccb1 100644 --- a/src/ui/main/serverView/uiPreviewPackage.ts +++ b/src/ui/main/serverView/uiPreviewPackage.ts @@ -5,7 +5,7 @@ import { gunzipSync } from 'zlib'; import { app, net } from 'electron'; -// Published by Rocket.Chat's `UI Preview Publish` workflow; public, so pulls need no GitHub login. +// Published by Rocket.Chat's `UI Preview` workflow; public, so pulls need no GitHub login. const registry = 'https://ghcr.io'; const repository = 'rocketchat/rocket.chat-web'; From 5cf022e0e1875c177034bae24c405e832883ec18 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 22:45:54 -0300 Subject: [PATCH 06/15] fix: point the UI preview dialog at Help > Developer Mode Co-Authored-By: Claude Opus 5.5 --- src/i18n/en.i18n.json | 2 +- src/i18n/pt-BR.i18n.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index d3b6ac9cae..78e3482774 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -209,7 +209,7 @@ "detail": "The UI will be loaded from {{- bundle}} and will run with your session on this server. Only continue if you trust this build. Use View > Restore server UI or restart the app to go back.", "load": "Load UI", "cancel": "Cancel", - "developerModeRequired": "Enable Developer Mode in Settings to load UI previews.", + "developerModeRequired": "Enable Help > Developer Mode to load UI previews.", "failed": "The UI preview could not be loaded." } }, diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index 249da0862d..d8fe4b6418 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -155,7 +155,7 @@ "detail": "A interface será carregada de {{- bundle}} e rodará com sua sessão neste servidor. Continue apenas se confiar neste build. Use Exibir > Restaurar UI do servidor ou reinicie o app para voltar.", "load": "Carregar UI", "cancel": "Cancelar", - "developerModeRequired": "Ative o Modo Desenvolvedor nas Configurações para carregar prévias de UI.", + "developerModeRequired": "Ative Ajuda > Modo desenvolvedor para carregar prévias de UI.", "failed": "Não foi possível carregar a prévia de UI." } }, From 8bafcff4e75eb8cf7fd6da7ed5c4aa4c5c819e19 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 22:54:31 -0300 Subject: [PATCH 07/15] feat: load UI previews from Settings > Developer Adds a "Web UI preview" section to the Developer tab (shown with Developer Mode on): pick a workspace, enter a Rocket.Chat PR number or a bundle URL, and Load or Restore. The section shows which preview is active on the selected workspace. The deep link and the section share one flow in serverView/uiPreview.ts (resolve the source, confirm, pull, apply). The new `ui-preview/*` IPC handlers only answer the root window, check Developer Mode and that the server is configured. Co-Authored-By: Claude Opus 5.5 --- src/deepLinks/main.ts | 86 ++---------- src/i18n/en.i18n.json | 12 +- src/i18n/pt-BR.i18n.json | 12 ++ src/ipc/channels.ts | 3 + src/main.ts | 2 + .../components/SettingsView/DeveloperTab.tsx | 5 + .../SettingsView/features/UiPreview.tsx | 129 ++++++++++++++++++ src/ui/components/SettingsView/tabs.spec.tsx | 7 +- src/ui/main/serverView/uiOverride.ts | 9 +- src/ui/main/serverView/uiPreview.main.spec.ts | 29 ++++ src/ui/main/serverView/uiPreview.ts | 120 ++++++++++++++++ 11 files changed, 337 insertions(+), 77 deletions(-) create mode 100644 src/ui/components/SettingsView/features/UiPreview.tsx create mode 100644 src/ui/main/serverView/uiPreview.main.spec.ts create mode 100644 src/ui/main/serverView/uiPreview.ts diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index 8d3c7fa2a3..0575c39aa8 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -1,5 +1,3 @@ -import { pathToFileURL } from 'url'; - import type { WebContents } from 'electron'; import { app } from 'electron'; @@ -14,18 +12,15 @@ import { openTelephonyDialpad } from '../telephony/dialpad'; import { parseTelephonyLink } from '../telephony/links'; import { askForServerAddition, - askForUiOverride, warnAboutInvalidServerUrl, - warnAboutUiOverrideRequiresDeveloperMode, - warnAboutUiPreviewFailure, } from '../ui/main/dialogs'; import { getRootWindow } from '../ui/main/rootWindow'; import { getWebContentsByServerUrl } from '../ui/main/serverView'; -import { applyUiOverride } from '../ui/main/serverView/uiOverride'; +import type { UiPreviewSource } from '../ui/main/serverView/uiPreview'; import { - getUiPreviewReference, - pullUiPreview, -} from '../ui/main/serverView/uiPreviewPackage'; + isUiPreviewAllowed, + requestUiPreview, +} from '../ui/main/serverView/uiPreview'; import { DEEP_LINKS_SERVER_FOCUSED, DEEP_LINKS_SERVER_ADDED } from './actions'; export type { TelephonyLink } from '../telephony/common'; @@ -267,71 +262,18 @@ const performAuthDeepLink = async (args: URLSearchParams): Promise => { } }; -type UiPreviewParams = { - host?: string; - bundle?: string; - pr?: string; - sha?: string; -}; - -const resolveUiPreviewSource = ({ - bundle, - pr, - sha, -}: UiPreviewParams): { label: string; load: () => Promise } | null => { - if (pr) { - if (!/^\d+$/.test(pr) || (sha && !/^[a-f0-9]{40}$/.test(sha))) { - return null; - } - const reference = getUiPreviewReference(sha ?? `pr-${pr}`); - return { - label: `PR #${pr} (${reference})`, - load: async () => - pathToFileURL(await pullUiPreview(sha ?? `pr-${pr}`)).href, - }; - } - - if (!bundle) { - return null; - } - try { - const { protocol, href } = new URL(bundle); - return protocol === 'https:' || protocol === 'http:' - ? { label: href, load: async () => href } - : null; - } catch { - return null; - } -}; - -const performUiPreview = async (params: UiPreviewParams): Promise => { - if (!select(({ isDeveloperModeEnabled }) => isDeveloperModeEnabled)) { - await warnAboutUiOverrideRequiresDeveloperMode(); - return; - } - - const source = resolveUiPreviewSource(params); - if (!source) { +const performUiPreview = async ({ + host, + ...source +}: UiPreviewSource & { host?: string }): Promise => { + if (!(await isUiPreviewAllowed())) { return; } - const applyToServer = async (serverUrl: string): Promise => { - if (!(await askForUiOverride(serverUrl, source.label))) { - return; - } - try { - const bundleUrl = await source.load(); - await getWebContents(serverUrl); - await applyUiOverride(serverUrl, bundleUrl, source.label); - } catch (error) { - await warnAboutUiPreviewFailure( - error instanceof Error ? error.message : String(error) - ); - } - }; - - if (params.host) { - await performOnServer(params.host, applyToServer); + if (host) { + await performOnServer(host, async (serverUrl) => { + await requestUiPreview(serverUrl, source); + }); return; } @@ -339,7 +281,7 @@ const performUiPreview = async (params: UiPreviewParams): Promise => { typeof currentView === 'object' ? currentView.url : undefined ); if (focusedServerUrl) { - await applyToServer(focusedServerUrl); + await requestUiPreview(focusedServerUrl, source); } }; diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 78e3482774..abb6c1ad76 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -335,7 +335,8 @@ "logging": "Logging", "telephony": "Telephony", "videoCalls": "Video calls", - "advanced": "Advanced" + "advanced": "Advanced", + "uiPreview": "Web UI preview" }, "options": { "report": { @@ -484,6 +485,15 @@ "e2ePdfPreviewSizeLimit": { "title": "PDF preview size limit in encrypted rooms (MB)", "description": "Encrypted files are loaded into memory for previews. Files larger than this limit will be downloaded directly." + }, + "uiPreview": { + "title": "Load a web UI build", + "description": "Run a Rocket.Chat web client build in place of the workspace's own UI, keeping your session. Enter a Rocket.Chat PR number (pulled from ghcr.io) or the URL of a served bundle.", + "server": "Workspace", + "placeholder": "42364 or http://127.0.0.1:4173/", + "load": "Load", + "restore": "Restore server UI", + "active": "Active on this workspace: {{- label}}" } }, "appearance": "Appearance", diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index d8fe4b6418..f8aa7da275 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -321,7 +321,19 @@ "auto": "Seguir sistema", "light": "Claro", "dark": "Escuro" + }, + "uiPreview": { + "title": "Carregar um build da UI web", + "description": "Roda um build do cliente web do Rocket.Chat no lugar da UI do workspace, mantendo sua sessão. Informe o número de um PR do Rocket.Chat (baixado do ghcr.io) ou a URL de um bundle servido.", + "server": "Workspace", + "placeholder": "42364 ou http://127.0.0.1:4173/", + "load": "Carregar", + "restore": "Restaurar UI do servidor", + "active": "Ativo neste workspace: {{- label}}" } + }, + "sections": { + "uiPreview": "Prévia de UI web" } }, "error": { diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index 0ce314f83d..17112282d6 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -25,6 +25,9 @@ type ChannelToArgsMap = { 'downloads/remove': (itemId: Download['itemId']) => void; 'certificatesManager/remove': (domain: string) => void; 'server-view/get-url': () => Server['url'] | undefined; + 'ui-preview/list': () => Record; + 'ui-preview/apply': (serverUrl: Server['url'], input: string) => boolean; + 'ui-preview/restore': (serverUrl: Server['url']) => void; 'server-view/ready': () => void; 'server-view/open-url-on-browser': (url: string) => void; 'video-call-window/open-window': ( diff --git a/src/main.ts b/src/main.ts index 04253cfcf6..1208657160 100644 --- a/src/main.ts +++ b/src/main.ts @@ -78,6 +78,7 @@ import { } from './ui/main/rootWindow'; import { startSecondaryWindowControlsHandler } from './ui/main/secondaryWindowControls'; import { attachGuestWebContentsEvents } from './ui/main/serverView'; +import { setupUiPreviewIpc } from './ui/main/serverView/uiPreview'; import touchBar from './ui/main/touchBar'; import trayIcon from './ui/main/trayIcon'; import { setupUpdates } from './updates/main'; @@ -154,6 +155,7 @@ const start = async (): Promise => { createRootWindow(); startOutlookCalendarUrlHandler(); attachGuestWebContentsEvents(); + setupUiPreviewIpc(); await showRootWindow(); // Mark main window as stable - GPU crashes after this won't trigger fallback diff --git a/src/ui/components/SettingsView/DeveloperTab.tsx b/src/ui/components/SettingsView/DeveloperTab.tsx index 3e7f4c4b68..3b1ef9d3e9 100644 --- a/src/ui/components/SettingsView/DeveloperTab.tsx +++ b/src/ui/components/SettingsView/DeveloperTab.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { DebugLogging } from './features/DebugLogging'; import { DetailedEventsLogging } from './features/DetailedEventsLogging'; +import { UiPreview } from './features/UiPreview'; import { VerboseOutlookLogging } from './features/VerboseOutlookLogging'; export const DeveloperTab = () => { @@ -17,6 +18,10 @@ export const DeveloperTab = () => { + + {t('settings.sections.uiPreview')} + + ); diff --git a/src/ui/components/SettingsView/features/UiPreview.tsx b/src/ui/components/SettingsView/features/UiPreview.tsx new file mode 100644 index 0000000000..6084898734 --- /dev/null +++ b/src/ui/components/SettingsView/features/UiPreview.tsx @@ -0,0 +1,129 @@ +import { Box, Button, Select, TextInput } from '@rocket.chat/fuselage'; +import type { ChangeEvent, FormEvent, Key } from 'react'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { invoke } from '../../../../ipc/renderer'; +import type { RootState } from '../../../../store/rootReducer'; +import { SettingField } from './SettingField'; + +type UiPreviewProps = { + className?: string; +}; + +export const UiPreview = (props: UiPreviewProps) => { + const servers = useSelector(({ servers }: RootState) => servers); + const focusedServerUrl = useSelector(({ currentView }: RootState) => + typeof currentView === 'object' ? currentView.url : undefined + ); + const { t } = useTranslation(); + const inputId = useId(); + + const [serverUrl, setServerUrl] = useState( + () => focusedServerUrl ?? servers[0]?.url + ); + const [input, setInput] = useState(''); + const [active, setActive] = useState>({}); + const [isBusy, setIsBusy] = useState(false); + + const refresh = useCallback(async () => { + setActive(await invoke('ui-preview/list')); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const options = useMemo( + (): [string, string][] => + servers.map((server): [string, string] => [ + server.url, + server.title ?? server.url, + ]), + [servers] + ); + + const run = async (action: () => Promise) => { + setIsBusy(true); + try { + await action(); + await refresh(); + } finally { + setIsBusy(false); + } + }; + + const handleLoad = (event: FormEvent) => { + event.preventDefault(); + if (serverUrl && input.trim()) { + run(() => invoke('ui-preview/apply', serverUrl, input.trim())); + } + }; + + const handleRestore = () => { + if (serverUrl) { + run(() => invoke('ui-preview/restore', serverUrl)); + } + }; + + if (servers.length === 0) { + return null; + } + + const activeLabel = serverUrl ? active[serverUrl] : undefined; + + return ( + + + setServerUrl(String(value))} - /> - - ) => - setInput(event.currentTarget.value) - } - /> - - - + + + {t('settings.options.uiPreview.title')} + + + {t('settings.options.uiPreview.description')} - + {servers.map((server) => ( + + ))} + ); }; diff --git a/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx b/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx new file mode 100644 index 0000000000..5490c92219 --- /dev/null +++ b/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx @@ -0,0 +1,86 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { invoke } from '../../../../ipc/renderer'; +import { UiPreviewRow } from './UiPreviewRow'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options ? `${key} ${Object.values(options).join(' ')}` : key, + }), +})); + +jest.mock('../../../../ipc/renderer', () => ({ + invoke: jest.fn(), +})); + +const server = { url: 'https://open.rocket.chat/', title: 'Open' }; + +const renderRow = (activeLabel?: string) => { + const onChanged = jest.fn().mockResolvedValue(undefined); + render( + + ); + return { onChanged }; +}; + +const load = (value: string) => { + fireEvent.change( + screen.getByPlaceholderText('settings.options.uiPreview.placeholder'), + { target: { value } } + ); + fireEvent.click(screen.getByText('settings.options.uiPreview.load')); +}; + +describe('UiPreviewRow', () => { + beforeEach(() => { + jest.mocked(invoke).mockReset(); + }); + + it('reports why a preview could not be loaded', async () => { + jest + .mocked(invoke) + .mockResolvedValue({ status: 'failed', message: 'responded 403' }); + const { onChanged } = renderRow(); + + load('42364'); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'settings.options.uiPreview.failed responded 403' + ); + expect(invoke).toHaveBeenCalledWith( + 'ui-preview/apply', + server.url, + '42364' + ); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); + + it('shows the active preview and lets it be restored', async () => { + jest.mocked(invoke).mockResolvedValue(undefined); + renderRow('PR #42364'); + + expect(screen.getByRole('status')).toHaveTextContent( + 'settings.options.uiPreview.active PR #42364' + ); + + fireEvent.click(screen.getByText('settings.options.uiPreview.restore')); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('ui-preview/restore', server.url) + ); + }); + + it('says the server UI is in use when nothing is loaded', () => { + renderRow(); + + expect(screen.getByRole('status')).toHaveTextContent( + 'settings.options.uiPreview.inactive' + ); + }); +}); diff --git a/src/ui/components/SettingsView/features/UiPreviewRow.tsx b/src/ui/components/SettingsView/features/UiPreviewRow.tsx new file mode 100644 index 0000000000..3895064f06 --- /dev/null +++ b/src/ui/components/SettingsView/features/UiPreviewRow.tsx @@ -0,0 +1,118 @@ +import { + Box, + Button, + Field, + FieldLabel, + TextInput, +} from '@rocket.chat/fuselage'; +import type { ChangeEvent, FormEvent } from 'react'; +import { useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { invoke } from '../../../../ipc/renderer'; +import type { Server } from '../../../../servers/common'; +import type { UiPreviewResult } from '../../../main/serverView/uiPreview'; + +type RowState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'done'; result: UiPreviewResult }; + +type UiPreviewRowProps = { + server: Server; + activeLabel: string | undefined; + onChanged: () => Promise; +}; + +export const UiPreviewRow = ({ + server, + activeLabel, + onChanged, +}: UiPreviewRowProps) => { + const { t } = useTranslation(); + const inputId = useId(); + const [input, setInput] = useState(''); + const [state, setState] = useState({ kind: 'idle' }); + + const isLoading = state.kind === 'loading'; + + const handleLoad = async (event: FormEvent) => { + event.preventDefault(); + if (!input.trim()) { + return; + } + setState({ kind: 'loading' }); + const result = await invoke('ui-preview/apply', server.url, input.trim()); + setState({ kind: 'done', result }); + await onChanged(); + }; + + const handleRestore = async () => { + setState({ kind: 'loading' }); + await invoke('ui-preview/restore', server.url); + setState({ kind: 'idle' }); + await onChanged(); + }; + + const status = (() => { + if (state.kind === 'loading') { + return { color: 'hint', text: t('settings.options.uiPreview.loading') }; + } + if (state.kind === 'done' && state.result.status === 'failed') { + return { + color: 'danger', + text: t('settings.options.uiPreview.failed', { + message: state.result.message, + }), + }; + } + if (activeLabel) { + return { + color: 'status-font-on-success', + text: t('settings.options.uiPreview.active', { label: activeLabel }), + }; + } + if (state.kind === 'done' && state.result.status === 'cancelled') { + return { color: 'hint', text: t('settings.options.uiPreview.cancelled') }; + } + return { color: 'hint', text: t('settings.options.uiPreview.inactive') }; + })(); + + return ( + + {server.title ?? server.url} + + {server.url} + + + ) => + setInput(event.currentTarget.value) + } + /> + + + + + {status.text} + + + ); +}; diff --git a/src/ui/main/serverView/uiPreview.ts b/src/ui/main/serverView/uiPreview.ts index 71723f27d3..b2e80048a1 100644 --- a/src/ui/main/serverView/uiPreview.ts +++ b/src/ui/main/serverView/uiPreview.ts @@ -1,5 +1,8 @@ import { pathToFileURL } from 'url'; +import type { WebContents } from 'electron'; +import { BrowserWindow } from 'electron'; + import { handle } from '../../../ipc/main'; import { select } from '../../../store'; import { @@ -55,8 +58,16 @@ export const parseUiPreviewInput = (input: string): UiPreviewSource => { return /^\d+$/.test(value) ? { pr: value } : { bundle: value }; }; +export type UiPreviewResult = + | { status: 'applied'; label: string } + | { status: 'cancelled' } + | { status: 'failed'; message: string }; + +const isDeveloperModeEnabled = () => + select(({ isDeveloperModeEnabled }) => isDeveloperModeEnabled); + export const isUiPreviewAllowed = async (): Promise => { - if (select(({ isDeveloperModeEnabled }) => isDeveloperModeEnabled)) { + if (isDeveloperModeEnabled()) { return true; } await warnAboutUiOverrideRequiresDeveloperMode(); @@ -65,34 +76,46 @@ export const isUiPreviewAllowed = async (): Promise => { export const requestUiPreview = async ( serverUrl: string, - source: UiPreviewSource -): Promise => { + source: UiPreviewSource, + parentWindow?: BrowserWindow +): Promise => { const resolved = resolveUiPreviewSource(source); if (!resolved) { - await warnAboutUiPreviewFailure( - `Not a PR number or an http(s) URL: ${source.bundle ?? source.pr ?? ''}` - ); - return false; + return { + status: 'failed', + message: `Not a PR number or an http(s) URL: ${source.bundle ?? source.pr ?? ''}`, + }; } - if (!(await askForUiOverride(serverUrl, resolved.label))) { - return false; + if (!(await askForUiOverride(serverUrl, resolved.label, parentWindow))) { + return { status: 'cancelled' }; } try { await applyUiOverride(serverUrl, await resolved.load(), resolved.label); - return true; + return { status: 'applied', label: resolved.label }; } catch (error) { - await warnAboutUiPreviewFailure( - error instanceof Error ? error.message : String(error) - ); - return false; + return { + status: 'failed', + message: error instanceof Error ? error.message : String(error), + }; + } +}; + +// The deep link has no page to report into, so its failures become a dialog. +export const requestUiPreviewWithDialog = async ( + serverUrl: string, + source: UiPreviewSource +): Promise => { + const result = await requestUiPreview(serverUrl, source); + if (result.status === 'failed') { + await warnAboutUiPreviewFailure(result.message); } }; export const setupUiPreviewIpc = (): void => { // Only the app's own pages (the root and settings windows) may ask; server content is never file://. - const isFromAppPage = (webContents: Electron.WebContents) => + const isFromAppPage = (webContents: WebContents) => webContents.getURL().startsWith('file://'); const isKnownServer = (serverUrl: string) => select(({ servers }) => servers.some((server) => server.url === serverUrl)); @@ -101,16 +124,22 @@ export const setupUiPreviewIpc = (): void => { isFromAppPage(webContents) ? listUiOverrides() : {} ); - handle('ui-preview/apply', async (webContents, serverUrl, input) => { - if ( - !isFromAppPage(webContents) || - !isKnownServer(serverUrl) || - !(await isUiPreviewAllowed()) - ) { - return false; + handle( + 'ui-preview/apply', + async (webContents, serverUrl, input): Promise => { + if (!isFromAppPage(webContents) || !isKnownServer(serverUrl)) { + return { status: 'failed', message: 'Request not allowed' }; + } + if (!isDeveloperModeEnabled()) { + return { status: 'failed', message: 'Developer Mode is off' }; + } + return requestUiPreview( + serverUrl, + parseUiPreviewInput(input), + BrowserWindow.fromWebContents(webContents) ?? undefined + ); } - return requestUiPreview(serverUrl, parseUiPreviewInput(input)); - }); + ); handle('ui-preview/restore', async (webContents, serverUrl) => { if (isFromAppPage(webContents)) { From 5708e6e3e3cbba1dedfcb49a0c2b1922763e0e74 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 23:12:28 -0300 Subject: [PATCH 10/15] fix: avoid nesting a form in the settings UI preview rows The settings sections already render inside a
, and a nested form is invalid HTML whose submit can reach the outer form. Load now runs on click or Enter in the field. Co-Authored-By: Claude Opus 5.5 --- .../SettingsView/features/UiPreviewRow.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ui/components/SettingsView/features/UiPreviewRow.tsx b/src/ui/components/SettingsView/features/UiPreviewRow.tsx index 3895064f06..da95abbf20 100644 --- a/src/ui/components/SettingsView/features/UiPreviewRow.tsx +++ b/src/ui/components/SettingsView/features/UiPreviewRow.tsx @@ -5,7 +5,7 @@ import { FieldLabel, TextInput, } from '@rocket.chat/fuselage'; -import type { ChangeEvent, FormEvent } from 'react'; +import type { ChangeEvent, KeyboardEvent } from 'react'; import { useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -36,9 +36,9 @@ export const UiPreviewRow = ({ const isLoading = state.kind === 'loading'; - const handleLoad = async (event: FormEvent) => { - event.preventDefault(); - if (!input.trim()) { + // Not a : the settings sections already render inside one. + const handleLoad = async () => { + if (!input.trim() || isLoading) { return; } setState({ kind: 'loading' }); @@ -79,7 +79,7 @@ export const UiPreviewRow = ({ })(); return ( - + {server.title ?? server.url} {server.url} @@ -92,13 +92,19 @@ export const UiPreviewRow = ({ onChange={(event: ChangeEvent) => setInput(event.currentTarget.value) } + onKeyDown={(event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleLoad(); + } + }} /> From dfcc09bbcac80ecf246bd56e8c0f02fa893ff31b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 23:21:56 -0300 Subject: [PATCH 11/15] feat: mark UI previews on the workspace tab instead of the page The "UI preview" banner injected into the page is gone. A server running a preview now carries a red "UI" badge on its tab (and sidebar entry), and the tab's tooltip says which build is loaded. The login warning still wins over it; mention and unread badges give way while a preview is on. The preview is tracked as `uiPreview` on the server in redux, cleared when settings load since previews do not survive a restart. Settings read it from there, so the `ui-preview/list` IPC channel is removed. Co-Authored-By: Claude Opus 5.5 --- src/i18n/en.i18n.json | 3 +- src/i18n/pt-BR.i18n.json | 3 +- src/ipc/channels.ts | 1 - src/servers/actions.ts | 5 ++ src/servers/common.ts | 2 + src/servers/reducers.ts | 16 +++++- .../SettingsView/features/UiPreview.tsx | 19 +------ .../features/UiPreviewRow.spec.tsx | 16 ++---- .../SettingsView/features/UiPreviewRow.tsx | 11 +--- src/ui/components/TabBar/WorkspaceTab.tsx | 17 +++++-- src/ui/components/TabBar/index.spec.tsx | 23 +++++++++ src/ui/components/TabBar/index.tsx | 1 + .../main/serverView/uiOverride.main.spec.ts | 23 +-------- src/ui/main/serverView/uiOverride.ts | 50 +++++++------------ src/ui/main/serverView/uiPreview.ts | 10 +--- 15 files changed, 90 insertions(+), 110 deletions(-) diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 7b51e87b9b..443ffdfca5 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -672,7 +672,8 @@ "canceled": "Canceled", "failed": "Failed", "dismiss": "Dismiss" - } + }, + "uiPreview": "UI preview: {{- label}}" }, "touchBar": { "formatting": "Formatting", diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index 1348c51e23..9d3f6a2c44 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -489,7 +489,8 @@ "canceled": "Cancelado", "failed": "Falhou", "dismiss": "Fechar" - } + }, + "uiPreview": "Prévia de UI: {{- label}}" }, "touchBar": { "formatting": "Formatação", diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index ae3d7fab47..a8eeb8af64 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -26,7 +26,6 @@ type ChannelToArgsMap = { 'downloads/remove': (itemId: Download['itemId']) => void; 'certificatesManager/remove': (domain: string) => void; 'server-view/get-url': () => Server['url'] | undefined; - 'ui-preview/list': () => Record; 'ui-preview/apply': ( serverUrl: Server['url'], input: string diff --git a/src/servers/actions.ts b/src/servers/actions.ts index c3c54cb640..b3f4c44041 100644 --- a/src/servers/actions.ts +++ b/src/servers/actions.ts @@ -6,6 +6,7 @@ export const SERVER_URL_RESOLUTION_REQUESTED = export const SERVER_URL_RESOLVED = 'server/url-resolved'; export const SERVER_DOCUMENT_VIEWER_OPEN_URL = 'server/document-viewer/open-url'; +export const SERVER_UI_PREVIEW_CHANGED = 'server/ui-preview-changed'; export type ServersActionTypeToPayloadMap = { [SERVERS_LOADED]: { @@ -19,4 +20,8 @@ export type ServersActionTypeToPayloadMap = { documentUrl: string; documentFormat?: string; }; + [SERVER_UI_PREVIEW_CHANGED]: { + url: Server['url']; + uiPreview: string | undefined; + }; }; diff --git a/src/servers/common.ts b/src/servers/common.ts index 64944f51ed..5cc558abf9 100644 --- a/src/servers/common.ts +++ b/src/servers/common.ts @@ -33,6 +33,8 @@ export type Server = { supportedVersionsValidatedAt?: Date; documentViewerOpenUrl?: string; documentViewerFormat?: string; + /** Label of the web UI preview loaded in place of the server's UI, if any. */ + uiPreview?: string; presence?: UserPresence; presenceStatusText?: string; presenceConnection?: 'connected' | 'connecting' | 'disconnected'; diff --git a/src/servers/reducers.ts b/src/servers/reducers.ts index f203fa53cf..c81e1f2fee 100644 --- a/src/servers/reducers.ts +++ b/src/servers/reducers.ts @@ -34,7 +34,11 @@ import { SUPPORTED_VERSION_DIALOG_DISMISS, WEBVIEW_SIDEBAR_CUSTOM_THEME_CHANGED, } from '../ui/actions'; -import { SERVERS_LOADED, SERVER_DOCUMENT_VIEWER_OPEN_URL } from './actions'; +import { + SERVERS_LOADED, + SERVER_DOCUMENT_VIEWER_OPEN_URL, + SERVER_UI_PREVIEW_CHANGED, +} from './actions'; import type { Server } from './common'; const ensureUrlFormat = (serverUrl: string | null): string => { @@ -68,6 +72,7 @@ type ServersActionTypes = | ActionOf | ActionOf | ActionOf + | ActionOf | ActionOf | ActionOf | ActionOf @@ -296,6 +301,8 @@ export const servers: Reducer = ( url: ensureUrlFormat(server.url), documentViewerOpenUrl: '', documentViewerFormat: '', + // Previews live in memory only, so none survive a restart. + uiPreview: undefined, })); } @@ -323,6 +330,13 @@ export const servers: Reducer = ( }); } + case SERVER_UI_PREVIEW_CHANGED: { + const { url, uiPreview } = action.payload; + return state.map((server) => + server.url === url ? { ...server, uiPreview } : server + ); + } + default: return state; } diff --git a/src/ui/components/SettingsView/features/UiPreview.tsx b/src/ui/components/SettingsView/features/UiPreview.tsx index 53080e096f..72b8f85926 100644 --- a/src/ui/components/SettingsView/features/UiPreview.tsx +++ b/src/ui/components/SettingsView/features/UiPreview.tsx @@ -1,9 +1,7 @@ import { Box } from '@rocket.chat/fuselage'; -import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import { invoke } from '../../../../ipc/renderer'; import type { RootState } from '../../../../store/rootReducer'; import { UiPreviewRow } from './UiPreviewRow'; @@ -14,16 +12,6 @@ type UiPreviewProps = { export const UiPreview = (props: UiPreviewProps) => { const servers = useSelector(({ servers }: RootState) => servers); const { t } = useTranslation(); - const [active, setActive] = useState>({}); - - const refresh = useCallback(async () => { - setActive(await invoke('ui-preview/list')); - }, []); - - useEffect(() => { - refresh(); - }, [refresh]); - if (servers.length === 0) { return null; } @@ -37,12 +25,7 @@ export const UiPreview = (props: UiPreviewProps) => { {t('settings.options.uiPreview.description')} {servers.map((server) => ( - + ))} ); diff --git a/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx b/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx index 5490c92219..c5ff5de06f 100644 --- a/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx +++ b/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx @@ -17,17 +17,8 @@ jest.mock('../../../../ipc/renderer', () => ({ const server = { url: 'https://open.rocket.chat/', title: 'Open' }; -const renderRow = (activeLabel?: string) => { - const onChanged = jest.fn().mockResolvedValue(undefined); - render( - - ); - return { onChanged }; -}; +const renderRow = (uiPreview?: string) => + render(); const load = (value: string) => { fireEvent.change( @@ -46,7 +37,7 @@ describe('UiPreviewRow', () => { jest .mocked(invoke) .mockResolvedValue({ status: 'failed', message: 'responded 403' }); - const { onChanged } = renderRow(); + renderRow(); load('42364'); @@ -58,7 +49,6 @@ describe('UiPreviewRow', () => { server.url, '42364' ); - await waitFor(() => expect(onChanged).toHaveBeenCalled()); }); it('shows the active preview and lets it be restored', async () => { diff --git a/src/ui/components/SettingsView/features/UiPreviewRow.tsx b/src/ui/components/SettingsView/features/UiPreviewRow.tsx index da95abbf20..0e68e30073 100644 --- a/src/ui/components/SettingsView/features/UiPreviewRow.tsx +++ b/src/ui/components/SettingsView/features/UiPreviewRow.tsx @@ -20,15 +20,10 @@ type RowState = type UiPreviewRowProps = { server: Server; - activeLabel: string | undefined; - onChanged: () => Promise; }; -export const UiPreviewRow = ({ - server, - activeLabel, - onChanged, -}: UiPreviewRowProps) => { +export const UiPreviewRow = ({ server }: UiPreviewRowProps) => { + const activeLabel = server.uiPreview; const { t } = useTranslation(); const inputId = useId(); const [input, setInput] = useState(''); @@ -44,14 +39,12 @@ export const UiPreviewRow = ({ setState({ kind: 'loading' }); const result = await invoke('ui-preview/apply', server.url, input.trim()); setState({ kind: 'done', result }); - await onChanged(); }; const handleRestore = async () => { setState({ kind: 'loading' }); await invoke('ui-preview/restore', server.url); setState({ kind: 'idle' }); - await onChanged(); }; const status = (() => { diff --git a/src/ui/components/TabBar/WorkspaceTab.tsx b/src/ui/components/TabBar/WorkspaceTab.tsx index 3b008d688d..0219f79943 100644 --- a/src/ui/components/TabBar/WorkspaceTab.tsx +++ b/src/ui/components/TabBar/WorkspaceTab.tsx @@ -62,6 +62,7 @@ type WorkspaceTabProps = { favicon: string | null; isSelected: boolean; badge?: '•' | number; + uiPreview?: string; userLoggedIn?: boolean; compact: boolean; orientation?: TabOrientation; @@ -80,6 +81,7 @@ const WorkspaceTab = ({ favicon, isSelected, badge, + uiPreview, userLoggedIn, compact, orientation = 'horizontal', @@ -128,9 +130,11 @@ const WorkspaceTab = ({ }${unreadSuffix}${shortcutSuffix}`; // Show the name on the first line and the address on a second line. When the // title is only the address, the primary line already is it, so skip line two. - const tooltipLines = tooltipName - ? [tooltipPrimaryLine, serverAddress] - : [tooltipPrimaryLine]; + const tooltipLines = [ + tooltipPrimaryLine, + ...(tooltipName ? [serverAddress] : []), + ...(uiPreview ? [t('tabBar.uiPreview', { label: uiPreview })] : []), + ]; // The TooltipProvider renders each '\n'-separated line on its own row, so the // native title, the custom hover tooltip and the aria-label all stay in sync. const tooltipText = tooltipLines.join('\n'); @@ -173,14 +177,17 @@ const WorkspaceTab = ({ const showLabel = !compact && !isVertical; // Exactly one badge at a time: a logged-out server's unread state is stale, - // so the login warning wins; otherwise a mention count beats the plain - // unread dot. The unread indicator itself depends on the layout: the + // so the login warning wins; then a loaded UI preview, which changes what + // the tab shows; otherwise a mention count beats the plain unread dot. The unread indicator itself depends on the layout: the // sidebar (vertical) gets the badge with the drawn dot, the tab strip // keeps the plain 8px ball. const badgeElement = ((): ReactNode => { if (!userLoggedIn) { return !; } + if (uiPreview) { + return UI; + } if (displayCount) { return {displayCount}; } diff --git a/src/ui/components/TabBar/index.spec.tsx b/src/ui/components/TabBar/index.spec.tsx index cd1ea1b2c2..33bfdad308 100644 --- a/src/ui/components/TabBar/index.spec.tsx +++ b/src/ui/components/TabBar/index.spec.tsx @@ -311,6 +311,29 @@ describe('TabBar', () => { expect(tabs[1]).toHaveAttribute('tabindex', '-1'); }); + it('marks a server running a UI preview on its tab instead of its mentions', () => { + const { container } = renderTabBar(, { + preloadedState: buildState({ + servers: [ + { + url: 'https://a.rocket.chat/', + title: 'Server A', + badge: 3, + userLoggedIn: true, + uiPreview: 'PR #42364', + }, + ], + }), + }); + + const badges = container.querySelectorAll('.rcx-badge'); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveTextContent('UI'); + expect(screen.getByRole('tab')).toHaveAccessibleName( + expect.stringContaining('tabBar.uiPreview') + ); + }); + it('shows a warning badge for logged-out servers', () => { renderTabBar(, { preloadedState: buildState({ diff --git a/src/ui/components/TabBar/index.tsx b/src/ui/components/TabBar/index.tsx index ece888e581..459e496798 100644 --- a/src/ui/components/TabBar/index.tsx +++ b/src/ui/components/TabBar/index.tsx @@ -151,6 +151,7 @@ export const TabBar = ({ favicon={server.favicon ?? null} isSelected={server.selected} badge={server.badge} + uiPreview={server.uiPreview} userLoggedIn={server.userLoggedIn} compact={compact} orientation={orientation} diff --git a/src/ui/main/serverView/uiOverride.main.spec.ts b/src/ui/main/serverView/uiOverride.main.spec.ts index f9148463ec..3512cef745 100644 --- a/src/ui/main/serverView/uiOverride.main.spec.ts +++ b/src/ui/main/serverView/uiOverride.main.spec.ts @@ -5,41 +5,22 @@ const viteIndex = describe('prepareIndexHtml', () => { it('injects the runtime config the Meteor server would provide', () => { - const html = prepareIndexHtml( - viteIndex, - 'https://open.rocket.chat', - 'https://builds.example/pr-1/' - ); + const html = prepareIndexHtml(viteIndex, 'https://open.rocket.chat'); expect(html).toContain( '' ); expect(html).toContain(''); - expect(html).toContain('UI preview: https://builds.example/pr-1/'); }); it('keeps a server path prefix in the base href and runtime config', () => { - const html = prepareIndexHtml( - viteIndex, - 'https://example.com/chat', - 'https://builds.example/pr-1/' - ); + const html = prepareIndexHtml(viteIndex, 'https://example.com/chat'); expect(html).toContain(''); expect(html).toContain( '"ROOT_URL":"https://example.com/chat/","ROOT_URL_PATH_PREFIX":"/chat"' ); }); - - it('escapes the bundle URL shown in the badge', () => { - const html = prepareIndexHtml( - viteIndex, - 'https://open.rocket.chat', - 'https://builds.example/">' - ); - - expect(html).not.toContain(''); - }); }); describe('isUiServedFromServer', () => { diff --git a/src/ui/main/serverView/uiOverride.ts b/src/ui/main/serverView/uiOverride.ts index 2632f395b3..d9cc281877 100644 --- a/src/ui/main/serverView/uiOverride.ts +++ b/src/ui/main/serverView/uiOverride.ts @@ -2,6 +2,8 @@ import type { Session } from 'electron'; import { session } from 'electron'; import { getWebContentsByServerUrl } from '.'; +import { SERVER_UI_PREVIEW_CHANGED } from '../../../servers/actions'; +import { dispatch } from '../../../store'; // Paths the Rocket.Chat server answers itself; mirrors `serverRoutes` in apps/meteor/vite/vite.config.mts. const serverRoutes = [ @@ -33,16 +35,7 @@ const serverRoutes = [ const bundleAssetsPath = '/bundle/'; // ponytail: in-memory only, so a restart always returns every server to its own UI. -const overrides = new Map(); - -const escapeHtml = (value: string) => - value.replace( - /[&<>"']/g, - (char) => - ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ - char - ] as string - ); +const overrides = new Set(); const withTrailingSlash = (url: string) => url.endsWith('/') ? url : `${url}/`; @@ -64,25 +57,18 @@ export const isUiServedFromServer = ( }; // The Meteor server injects the runtime config and base path into its HTML; the static bundle's index.html has neither. -export const prepareIndexHtml = ( - html: string, - serverUrl: string, - label: string -) => { +export const prepareIndexHtml = (html: string, serverUrl: string) => { const { origin, pathname } = new URL(withTrailingSlash(serverUrl)); const runtimeConfig = JSON.stringify({ ROOT_URL: origin + pathname, ROOT_URL_PATH_PREFIX: pathname.replace(/\/$/, ''), }); - const badge = `
UI preview: ${escapeHtml(label)}
`; - return html .replace(//, ``) .replace( '', `` - ) - .replace('', `${badge}`); + ); }; const passthrough = (ses: Session, request: Request) => @@ -93,7 +79,7 @@ const passthrough = (ses: Session, request: Request) => // ponytail: every request of the server's scheme in this session goes through the main process while an override is active; fine for testing, not for daily use. const createHandler = - (ses: Session, serverUrl: string, bundleUrl: string, label: string) => + (ses: Session, serverUrl: string, bundleUrl: string) => async (request: Request): Promise => { const server = new URL(withTrailingSlash(serverUrl)); const url = new URL(request.url); @@ -124,10 +110,9 @@ const createHandler = return index; } - return new Response( - prepareIndexHtml(await index.text(), serverUrl, label), - { headers: { 'content-type': 'text/html; charset=utf-8' } } - ); + return new Response(prepareIndexHtml(await index.text(), serverUrl), { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); }; const reloadServer = async (serverUrl: string, ses: Session) => { @@ -137,11 +122,6 @@ const reloadServer = async (serverUrl: string, ses: Session) => { getWebContentsByServerUrl(serverUrl)?.reloadIgnoringCache(); }; -export const listUiOverrides = (): Record => - Object.fromEntries( - [...overrides].map(([serverUrl, { label }]) => [serverUrl, label]) - ); - export const applyUiOverride = async ( serverUrl: string, bundleUrl: string, @@ -156,9 +136,13 @@ export const applyUiOverride = async ( } ses.protocol.handle( scheme, - createHandler(ses, serverUrl, normalizedBundleUrl, label) + createHandler(ses, serverUrl, normalizedBundleUrl) ); - overrides.set(serverUrl, { label }); + overrides.add(serverUrl); + dispatch({ + type: SERVER_UI_PREVIEW_CHANGED, + payload: { url: serverUrl, uiPreview: label }, + }); await reloadServer(serverUrl, ses); }; @@ -170,6 +154,10 @@ export const clearUiOverride = async (serverUrl: string) => { const ses = getServerSession(serverUrl); ses.protocol.unhandle(getScheme(serverUrl)); + dispatch({ + type: SERVER_UI_PREVIEW_CHANGED, + payload: { url: serverUrl, uiPreview: undefined }, + }); await reloadServer(serverUrl, ses); }; diff --git a/src/ui/main/serverView/uiPreview.ts b/src/ui/main/serverView/uiPreview.ts index b2e80048a1..a7010630ba 100644 --- a/src/ui/main/serverView/uiPreview.ts +++ b/src/ui/main/serverView/uiPreview.ts @@ -10,11 +10,7 @@ import { warnAboutUiOverrideRequiresDeveloperMode, warnAboutUiPreviewFailure, } from '../dialogs'; -import { - applyUiOverride, - clearUiOverride, - listUiOverrides, -} from './uiOverride'; +import { applyUiOverride, clearUiOverride } from './uiOverride'; import { getUiPreviewReference, pullUiPreview } from './uiPreviewPackage'; export type UiPreviewSource = { @@ -120,10 +116,6 @@ export const setupUiPreviewIpc = (): void => { const isKnownServer = (serverUrl: string) => select(({ servers }) => servers.some((server) => server.url === serverUrl)); - handle('ui-preview/list', async (webContents) => - isFromAppPage(webContents) ? listUiOverrides() : {} - ); - handle( 'ui-preview/apply', async (webContents, serverUrl, input): Promise => { From 88fa94924f8359123bc26f04f6fb76719aa8cd24 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 25 Sep 2026 23:24:55 -0300 Subject: [PATCH 12/15] feat: show the UI preview badge alongside the tab's other badge The preview badge no longer takes part in the one-badge rule: the login warning, mention count and unread dot keep their priority, and a tab running a preview also shows "UI". In the vertical sidebar it sits in the bottom-right corner so both fit. Co-Authored-By: Claude Opus 5.5 --- src/ui/components/TabBar/WorkspaceTab.tsx | 26 +++++++++++++++++------ src/ui/components/TabBar/index.spec.tsx | 7 +++--- src/ui/components/TabBar/styles.tsx | 8 +++++++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/ui/components/TabBar/WorkspaceTab.tsx b/src/ui/components/TabBar/WorkspaceTab.tsx index 0219f79943..f703b97d8d 100644 --- a/src/ui/components/TabBar/WorkspaceTab.tsx +++ b/src/ui/components/TabBar/WorkspaceTab.tsx @@ -24,6 +24,7 @@ import { Favicon, Initials, Label, + PreviewBadgeWrapper, ShortcutChip, Tab, TabBadge, @@ -177,17 +178,14 @@ const WorkspaceTab = ({ const showLabel = !compact && !isVertical; // Exactly one badge at a time: a logged-out server's unread state is stale, - // so the login warning wins; then a loaded UI preview, which changes what - // the tab shows; otherwise a mention count beats the plain unread dot. The unread indicator itself depends on the layout: the + // so the login warning wins; otherwise a mention count beats the plain + // unread dot. The unread indicator itself depends on the layout: the // sidebar (vertical) gets the badge with the drawn dot, the tab strip // keeps the plain 8px ball. const badgeElement = ((): ReactNode => { if (!userLoggedIn) { return !; } - if (uiPreview) { - return UI; - } if (displayCount) { return {displayCount}; } @@ -201,6 +199,12 @@ const WorkspaceTab = ({ return null; })(); + // Shown alongside the badge above, never instead of it: it says what the + // tab is running, not what is waiting in it. + const uiPreviewBadge = uiPreview ? ( + UI + ) : null; + return ( <> {shortcutNumber} )} {isVertical ? ( - {badgeElement} + <> + {badgeElement} + {uiPreviewBadge && ( + {uiPreviewBadge} + )} + ) : ( - badgeElement + <> + {uiPreviewBadge} + {badgeElement} + )} diff --git a/src/ui/components/TabBar/index.spec.tsx b/src/ui/components/TabBar/index.spec.tsx index 33bfdad308..f817fd720f 100644 --- a/src/ui/components/TabBar/index.spec.tsx +++ b/src/ui/components/TabBar/index.spec.tsx @@ -311,7 +311,7 @@ describe('TabBar', () => { expect(tabs[1]).toHaveAttribute('tabindex', '-1'); }); - it('marks a server running a UI preview on its tab instead of its mentions', () => { + it('marks a server running a UI preview on its tab next to its mentions', () => { const { container } = renderTabBar(, { preloadedState: buildState({ servers: [ @@ -326,9 +326,8 @@ describe('TabBar', () => { }), }); - const badges = container.querySelectorAll('.rcx-badge'); - expect(badges).toHaveLength(1); - expect(badges[0]).toHaveTextContent('UI'); + const badges = [...container.querySelectorAll('.rcx-badge')]; + expect(badges.map((badge) => badge.textContent)).toEqual(['UI', '3']); expect(screen.getByRole('tab')).toHaveAccessibleName( expect.stringContaining('tabBar.uiPreview') ); diff --git a/src/ui/components/TabBar/styles.tsx b/src/ui/components/TabBar/styles.tsx index c4210c1d6d..b5b906247f 100644 --- a/src/ui/components/TabBar/styles.tsx +++ b/src/ui/components/TabBar/styles.tsx @@ -434,6 +434,14 @@ export const BadgeWrapper = styled.div` pointer-events: none; `; +/* The UI preview badge takes the bottom-right corner, below the mention/ + warning badge, so a vertical tab can show both. */ +export const PreviewBadgeWrapper = styled(BadgeWrapper)` + top: auto; + bottom: ${BADGE_ANCHOR_Y}; + transform: translate(-50%, 50%); +`; + export const WindowControlsGroup = styled.div` display: flex; flex-direction: row; From bc4485127ddb0789191ef71d4ac644e2235dfa01 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 28 Sep 2026 13:59:49 -0300 Subject: [PATCH 13/15] fix: recover the UI preview row when load or restore rejects A rejected ui-preview/apply or ui-preview/restore left the row loading with both buttons disabled. Show the error in the row's status instead, so the action can be retried. --- .../features/UiPreviewRow.spec.tsx | 28 +++++++++++++++++++ .../SettingsView/features/UiPreviewRow.tsx | 24 +++++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx b/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx index c5ff5de06f..fee8ed9daf 100644 --- a/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx +++ b/src/ui/components/SettingsView/features/UiPreviewRow.spec.tsx @@ -66,6 +66,34 @@ describe('UiPreviewRow', () => { ); }); + it('reports a rejected load and lets it be retried', async () => { + jest.mocked(invoke).mockRejectedValue(new Error('dialog failed')); + renderRow(); + + load('42364'); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'settings.options.uiPreview.failed dialog failed' + ); + expect( + screen.getByText('settings.options.uiPreview.load').closest('button') + ).toBeEnabled(); + }); + + it('reports a rejected restore and lets it be retried', async () => { + jest.mocked(invoke).mockRejectedValue(new Error('clearCache failed')); + renderRow('PR #42364'); + + fireEvent.click(screen.getByText('settings.options.uiPreview.restore')); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'settings.options.uiPreview.failed clearCache failed' + ); + expect( + screen.getByText('settings.options.uiPreview.restore').closest('button') + ).toBeEnabled(); + }); + it('says the server UI is in use when nothing is loaded', () => { renderRow(); diff --git a/src/ui/components/SettingsView/features/UiPreviewRow.tsx b/src/ui/components/SettingsView/features/UiPreviewRow.tsx index 0e68e30073..f26bcd5909 100644 --- a/src/ui/components/SettingsView/features/UiPreviewRow.tsx +++ b/src/ui/components/SettingsView/features/UiPreviewRow.tsx @@ -18,6 +18,14 @@ type RowState = | { kind: 'loading' } | { kind: 'done'; result: UiPreviewResult }; +const failedState = (error: unknown): RowState => ({ + kind: 'done', + result: { + status: 'failed', + message: error instanceof Error ? error.message : String(error), + }, +}); + type UiPreviewRowProps = { server: Server; }; @@ -37,14 +45,22 @@ export const UiPreviewRow = ({ server }: UiPreviewRowProps) => { return; } setState({ kind: 'loading' }); - const result = await invoke('ui-preview/apply', server.url, input.trim()); - setState({ kind: 'done', result }); + try { + const result = await invoke('ui-preview/apply', server.url, input.trim()); + setState({ kind: 'done', result }); + } catch (error) { + setState(failedState(error)); + } }; const handleRestore = async () => { setState({ kind: 'loading' }); - await invoke('ui-preview/restore', server.url); - setState({ kind: 'idle' }); + try { + await invoke('ui-preview/restore', server.url); + setState({ kind: 'idle' }); + } catch (error) { + setState(failedState(error)); + } }; const status = (() => { From 97a919bc87dbf13e6331b130456fcec0e10cace3 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 28 Sep 2026 13:59:49 -0300 Subject: [PATCH 14/15] fix: keep a completed UI preview bundle during overlapping pulls Two pulls of the same layer digest shared one partial directory, and the second removed the first one's completed bundle before renaming its own, while the first caller may already be serving from it. Extract each pull into its own temporary directory and keep an existing completed bundle. The temporary directory is removed even when extraction fails. --- .../serverView/uiPreviewPackage.main.spec.ts | 62 ++++++++++++++++++- src/ui/main/serverView/uiPreviewPackage.ts | 17 +++-- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/ui/main/serverView/uiPreviewPackage.main.spec.ts b/src/ui/main/serverView/uiPreviewPackage.main.spec.ts index 712cceb286..e10cca9544 100644 --- a/src/ui/main/serverView/uiPreviewPackage.main.spec.ts +++ b/src/ui/main/serverView/uiPreviewPackage.main.spec.ts @@ -1,8 +1,12 @@ +import { createHash } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import { gzipSync } from 'zlib'; -import { extractTar } from './uiPreviewPackage'; +import { app, net } from 'electron'; + +import { extractTar, pullUiPreview } from './uiPreviewPackage'; const ustarEntry = (name: string, data = '', type = '0') => { const header = Buffer.alloc(512); @@ -61,3 +65,59 @@ describe('extractTar', () => { expect(fs.existsSync(path.join(dir, 'link'))).toBe(false); }); }); + +describe('pullUiPreview', () => { + let userData: string; + + beforeEach(() => { + userData = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-preview-user-')); + jest.spyOn(app, 'getPath').mockReturnValue(userData); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(userData, { recursive: true, force: true }); + }); + + it('keeps a completed bundle when two pulls of one digest overlap', async () => { + const blob = gzipSync(tarOf(ustarEntry('index.html', ''))); + const hash = createHash('sha256').update(blob).digest('hex'); + const blobGates: Array<() => void> = []; + let bothWaiting: () => void; + const bothPullsMissedCache = new Promise((resolve) => { + bothWaiting = resolve; + }); + jest.spyOn(net, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.includes('/token')) { + return new Response(JSON.stringify({ token: 'anonymous' })); + } + if (url.includes('/manifests/')) { + return new Response( + JSON.stringify({ layers: [{ digest: `sha256:${hash}` }] }) + ); + } + await new Promise((resolve) => { + blobGates.push(resolve); + if (blobGates.length === 2) { + bothWaiting(); + } + }); + return new Response(blob); + }); + + const first = pullUiPreview('pr-1'); + const second = pullUiPreview('pr-1'); + await bothPullsMissedCache; + + blobGates[0](); + const dir = await first; + const index = path.join(dir, 'index.html'); + const { ino } = fs.statSync(index); + + blobGates[1](); + expect(await second).toBe(dir); + expect(fs.statSync(index).ino).toBe(ino); + expect(fs.readdirSync(path.dirname(dir))).toEqual([hash]); + }); +}); diff --git a/src/ui/main/serverView/uiPreviewPackage.ts b/src/ui/main/serverView/uiPreviewPackage.ts index 750ba0ccb1..4c28a58d69 100644 --- a/src/ui/main/serverView/uiPreviewPackage.ts +++ b/src/ui/main/serverView/uiPreviewPackage.ts @@ -96,11 +96,18 @@ export const pullUiPreview = async (tag: string): Promise => { throw new Error(`${getUiPreviewReference(tag)} failed digest check`); } - const partialDir = `${dir}.partial`; - fs.rmSync(partialDir, { recursive: true, force: true }); - extractTar(gunzipSync(blob), partialDir); - fs.rmSync(dir, { recursive: true, force: true }); - fs.renameSync(partialDir, dir); + // Unique per pull, so a concurrent pull of the same digest never removes a completed bundle. + fs.mkdirSync(path.dirname(dir), { recursive: true }); + const partialDir = fs.mkdtempSync(`${dir}.partial-`); + try { + extractTar(gunzipSync(blob), partialDir); + if (!fs.existsSync(path.join(dir, 'index.html'))) { + fs.rmSync(dir, { recursive: true, force: true }); + fs.renameSync(partialDir, dir); + } + } finally { + fs.rmSync(partialDir, { recursive: true, force: true }); + } return dir; }; From 503bde9ff5d144514c7fc5acd9a86b9c4e385abc Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 28 Sep 2026 13:59:49 -0300 Subject: [PATCH 15/15] i18n: translate the UI preview strings into every language --- src/i18n/ar.i18n.json | 32 +++++++++++++++++++++++++++++++- src/i18n/de-DE.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/es.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/fi.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/fr.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/hu.i18n.json | 30 +++++++++++++++++++++++++++--- src/i18n/it-IT.i18n.json | 32 ++++++++++++++++++++++++++++++-- src/i18n/ja.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/nb-NO.i18n.json | 32 +++++++++++++++++++++++++++++++- src/i18n/nn.i18n.json | 32 +++++++++++++++++++++++++++++++- src/i18n/no.i18n.json | 30 +++++++++++++++++++++++++++--- src/i18n/pl.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/ru.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/se.i18n.json | 32 +++++++++++++++++++++++++++++++- src/i18n/sv.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/tr-TR.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/uk-UA.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/zh-CN.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/zh-TW.i18n.json | 30 ++++++++++++++++++++++++++++-- src/i18n/zh.i18n.json | 32 +++++++++++++++++++++++++++++++- 20 files changed, 575 insertions(+), 37 deletions(-) diff --git a/src/i18n/ar.i18n.json b/src/i18n/ar.i18n.json index 15322bb105..00b4ca0340 100644 --- a/src/i18n/ar.i18n.json +++ b/src/i18n/ar.i18n.json @@ -31,7 +31,22 @@ "menuBarUnreadCount": { "title": "عدد غير المقروءة في شريط القوائم", "description": "عرض عدد الإشارات غير المقروءة بجوار أيقونة شريط القوائم." + }, + "uiPreview": { + "title": "تحميل إصدار من واجهة الويب", + "description": "يشغّل إصدارًا من عميل ويب Rocket.Chat بدلًا من واجهة مساحة العمل، مع الإبقاء على جلستك. أدخل رقم طلب دمج (PR) في Rocket.Chat (يُسحب من ghcr.io) أو عنوان URL لحزمة مُقدَّمة.", + "placeholder": "42364 أو http://127.0.0.1:4173/", + "load": "تحميل", + "restore": "استعادة واجهة الخادم", + "active": "نشطة في مساحة العمل هذه: {{- label}}", + "loading": "جارٍ التنزيل والتطبيق…", + "failed": "تعذّر التحميل: {{- message}}", + "cancelled": "تم الإلغاء.", + "inactive": "تُستخدم واجهة الخادم." } + }, + "sections": { + "uiPreview": "معاينة واجهة الويب" } }, "serverInfo": { @@ -62,7 +77,8 @@ }, "menus": { "simulateDisconnected": "محاكاة قطع الاتصال", - "resetWindowBounds": "إعادة تعيين أحجام النوافذ ومواضعها" + "resetWindowBounds": "إعادة تعيين أحجام النوافذ ومواضعها", + "restoreServerUi": "استعادة واجهة الخادم" }, "tray": { "presence": { @@ -75,5 +91,19 @@ "signIn": "تسجيل الدخول إلى {{- workspace}}", "addWorkspace": "إضافة مساحة عمل" } + }, + "dialog": { + "uiOverride": { + "title": "تحميل معاينة الواجهة", + "message": "هل تريد تحميل واجهة ويب مختلفة لـ \"{{- host}}\"؟", + "detail": "سيتم تحميل الواجهة من {{- bundle}} وستعمل بجلستك على هذا الخادم. تابع فقط إذا كنت تثق بهذا الإصدار. استخدم View > استعادة واجهة الخادم أو أعد تشغيل التطبيق للرجوع.", + "load": "تحميل الواجهة", + "cancel": "إلغاء", + "developerModeRequired": "فعّل Help > Developer Mode لتحميل معاينات الواجهة.", + "failed": "تعذّر تحميل معاينة الواجهة." + } + }, + "tabBar": { + "uiPreview": "معاينة الواجهة: {{- label}}" } } diff --git a/src/i18n/de-DE.i18n.json b/src/i18n/de-DE.i18n.json index e9fb0ca549..8f8d8ec96d 100644 --- a/src/i18n/de-DE.i18n.json +++ b/src/i18n/de-DE.i18n.json @@ -129,6 +129,15 @@ "answerCall": "Anrufe entgegennehmen", "recordMessage": "Nachrichten aufnehmen" } + }, + "uiOverride": { + "title": "UI-Vorschau laden", + "message": "Eine andere Web-Oberfläche für „{{- host}}“ laden?", + "detail": "Die Oberfläche wird von {{- bundle}} geladen und läuft mit deiner Sitzung auf diesem Server. Fahre nur fort, wenn du diesem Build vertraust. Nutze Darstellung > Server-UI wiederherstellen oder starte die App neu, um zurückzukehren.", + "load": "UI laden", + "cancel": "Abbrechen", + "developerModeRequired": "Aktiviere Hilfe > Entwicklermodus, um UI-Vorschauen zu laden.", + "failed": "Die UI-Vorschau konnte nicht geladen werden." } }, "documentViewer": { @@ -294,7 +303,22 @@ "auto": "System folgen", "light": "Hell", "dark": "Dunkel" + }, + "uiPreview": { + "title": "Einen Web-UI-Build laden", + "description": "Führt einen Build des Rocket.Chat-Webclients anstelle der eigenen Oberfläche eines Workspaces aus und behält deine Sitzung bei. Gib eine Rocket.Chat-PR-Nummer (von ghcr.io bezogen) oder die URL eines bereitgestellten Bundles ein.", + "placeholder": "42364 oder http://127.0.0.1:4173/", + "load": "Laden", + "restore": "Server-UI wiederherstellen", + "active": "Aktiv in diesem Workspace: {{- label}}", + "loading": "Wird heruntergeladen und angewendet…", + "failed": "Laden fehlgeschlagen: {{- message}}", + "cancelled": "Abgebrochen.", + "inactive": "Die UI des Servers wird verwendet." } + }, + "sections": { + "uiPreview": "Web-UI-Vorschau" } }, "error": { @@ -354,7 +378,8 @@ "windowMenu": "&Fenster", "zoomIn": "Vergrößern", "zoomOut": "Verkleinern", - "simulateDisconnected": "Verbindungsabbruch simulieren" + "simulateDisconnected": "Verbindungsabbruch simulieren", + "restoreServerUi": "Server-UI wiederherstellen" }, "loadingError": { "title": "Server konnte nicht geladen werden", @@ -429,7 +454,8 @@ "canceled": "Abgebrochen", "failed": "Fehlgeschlagen", "dismiss": "Schließen" - } + }, + "uiPreview": "UI-Vorschau: {{- label}}" }, "touchBar": { "formatting": "Formatierung", diff --git a/src/i18n/es.i18n.json b/src/i18n/es.i18n.json index a6ff577a9e..46dba9b46a 100644 --- a/src/i18n/es.i18n.json +++ b/src/i18n/es.i18n.json @@ -148,6 +148,15 @@ }, "supportedVersion": { "title": "Versión de espacio de trabajo no admitida" + }, + "uiOverride": { + "title": "Cargar vista previa de la UI", + "message": "¿Cargar una interfaz web diferente para \"{{- host}}\"?", + "detail": "La interfaz se cargará desde {{- bundle}} y se ejecutará con tu sesión en este servidor. Continúa solo si confías en esta compilación. Usa Ver > Restaurar la UI del servidor o reinicia la aplicación para volver.", + "load": "Cargar UI", + "cancel": "Cancelar", + "developerModeRequired": "Activa Ayuda > Modo desarrollador para cargar vistas previas de la UI.", + "failed": "No se pudo cargar la vista previa de la UI." } }, "documentViewer": { @@ -317,7 +326,22 @@ "auto": "Seguir sistema", "light": "Claro", "dark": "Oscuro" + }, + "uiPreview": { + "title": "Cargar una compilación de la UI web", + "description": "Ejecuta una compilación del cliente web de Rocket.Chat en lugar de la UI propia de un espacio de trabajo, manteniendo tu sesión. Introduce un número de PR de Rocket.Chat (descargado de ghcr.io) o la URL de un bundle servido.", + "placeholder": "42364 o http://127.0.0.1:4173/", + "load": "Cargar", + "restore": "Restaurar la UI del servidor", + "active": "Activa en este espacio de trabajo: {{- label}}", + "loading": "Descargando y aplicando…", + "failed": "No se pudo cargar: {{- message}}", + "cancelled": "Cancelado.", + "inactive": "Usando la UI del servidor." } + }, + "sections": { + "uiPreview": "Vista previa de la UI web" } }, "error": { @@ -378,7 +402,8 @@ "windowMenu": "&Ventana", "zoomIn": "Acercar", "zoomOut": "Alejar", - "simulateDisconnected": "Simular desconexión" + "simulateDisconnected": "Simular desconexión", + "restoreServerUi": "Restaurar la UI del servidor" }, "loadingError": { "title": "Fallo al cargar el servidor", @@ -459,7 +484,8 @@ "canceled": "Cancelada", "failed": "Fallida", "dismiss": "Cerrar" - } + }, + "uiPreview": "Vista previa de la UI: {{- label}}" }, "touchBar": { "formatting": "Formato", diff --git a/src/i18n/fi.i18n.json b/src/i18n/fi.i18n.json index e45280d90b..3714b5bed0 100644 --- a/src/i18n/fi.i18n.json +++ b/src/i18n/fi.i18n.json @@ -136,6 +136,15 @@ "answerCall": "vastata puheluun", "recordMessage": "tallentaa viestin" } + }, + "uiOverride": { + "title": "Lataa käyttöliittymän esikatselu", + "message": "Ladataanko palvelimelle \"{{- host}}\" toinen verkkokäyttöliittymä?", + "detail": "Käyttöliittymä ladataan osoitteesta {{- bundle}}, ja se toimii istunnollasi tällä palvelimella. Jatka vain, jos luotat tähän koontiversioon. Palaa takaisin valitsemalla Näytä > Palauta palvelimen käyttöliittymä tai käynnistämällä sovellus uudelleen.", + "load": "Lataa käyttöliittymä", + "cancel": "Peruuta", + "developerModeRequired": "Ota Ohje > Developer Mode käyttöön ladataksesi käyttöliittymän esikatseluja.", + "failed": "Käyttöliittymän esikatselua ei voitu ladata." } }, "documentViewer": { @@ -292,7 +301,22 @@ "auto": "Seuraa järjestelmää", "light": "Vaalea", "dark": "Tumma" + }, + "uiPreview": { + "title": "Lataa verkkokäyttöliittymän koontiversio", + "description": "Suorittaa Rocket.Chat-verkkoasiakkaan koontiversion työtilan oman käyttöliittymän sijaan istuntosi säilyttäen. Anna Rocket.Chatin PR-numero (haetaan ghcr.io:sta) tai tarjoillun paketin URL-osoite.", + "placeholder": "42364 tai http://127.0.0.1:4173/", + "load": "Lataa", + "restore": "Palauta palvelimen käyttöliittymä", + "active": "Käytössä tässä työtilassa: {{- label}}", + "loading": "Ladataan ja otetaan käyttöön…", + "failed": "Lataus epäonnistui: {{- message}}", + "cancelled": "Peruttu.", + "inactive": "Käytetään palvelimen käyttöliittymää." } + }, + "sections": { + "uiPreview": "Verkkokäyttöliittymän esikatselu" } }, "error": { @@ -352,7 +376,8 @@ "windowMenu": "&Ikkuna", "zoomIn": "Lähennä", "zoomOut": "Loitonna", - "simulateDisconnected": "Simuloi yhteyden katkeamista" + "simulateDisconnected": "Simuloi yhteyden katkeamista", + "restoreServerUi": "Palauta palvelimen käyttöliittymä" }, "loadingError": { "title": "Palvelimen lataus epäonnistui", @@ -427,7 +452,8 @@ "canceled": "Peruutettu", "failed": "Epäonnistui", "dismiss": "Sulje" - } + }, + "uiPreview": "Käyttöliittymän esikatselu: {{- label}}" }, "touchBar": { "formatting": "Muotoilu", diff --git a/src/i18n/fr.i18n.json b/src/i18n/fr.i18n.json index f467620e05..4cbfbcef7c 100644 --- a/src/i18n/fr.i18n.json +++ b/src/i18n/fr.i18n.json @@ -136,6 +136,15 @@ "answerCall": "répondre aux appels", "recordMessage": "enregistrer des messages" } + }, + "uiOverride": { + "title": "Charger un aperçu de l'interface", + "message": "Charger une autre interface web pour « {{- host}} » ?", + "detail": "L'interface sera chargée depuis {{- bundle}} et fonctionnera avec votre session sur ce serveur. Ne continuez que si vous faites confiance à ce build. Utilisez Voir > Restaurer l'interface du serveur ou redémarrez l'application pour revenir en arrière.", + "load": "Charger l'interface", + "cancel": "Annuler", + "developerModeRequired": "Activez Aide > Mode développeur pour charger des aperçus de l'interface.", + "failed": "L'aperçu de l'interface n'a pas pu être chargé." } }, "documentViewer": { @@ -292,7 +301,22 @@ "auto": "Suivre le système", "light": "Clair", "dark": "Sombre" + }, + "uiPreview": { + "title": "Charger un build de l'interface web", + "description": "Exécute un build du client web Rocket.Chat à la place de l'interface propre à un espace de travail, en conservant votre session. Saisissez un numéro de PR Rocket.Chat (récupéré depuis ghcr.io) ou l'URL d'un bundle servi.", + "placeholder": "42364 ou http://127.0.0.1:4173/", + "load": "Charger", + "restore": "Restaurer l'interface du serveur", + "active": "Actif sur cet espace de travail : {{- label}}", + "loading": "Téléchargement et application…", + "failed": "Chargement impossible : {{- message}}", + "cancelled": "Annulé.", + "inactive": "Utilisation de l'interface du serveur." } + }, + "sections": { + "uiPreview": "Aperçu de l'interface web" } }, "error": { @@ -353,7 +377,8 @@ "windowMenu": "&Fenêtre", "zoomIn": "Zoomer", "zoomOut": "Zoom arrière", - "simulateDisconnected": "Simuler une déconnexion" + "simulateDisconnected": "Simuler une déconnexion", + "restoreServerUi": "Restaurer l'interface du serveur" }, "loadingError": { "title": "Echec du chargement du serveur", @@ -428,7 +453,8 @@ "canceled": "Annulé", "failed": "Échoué", "dismiss": "Fermer" - } + }, + "uiPreview": "Aperçu de l'interface : {{- label}}" }, "touchBar": { "formatting": "Mise en page", diff --git a/src/i18n/hu.i18n.json b/src/i18n/hu.i18n.json index 2fecd90d5a..d9d927a670 100644 --- a/src/i18n/hu.i18n.json +++ b/src/i18n/hu.i18n.json @@ -157,6 +157,15 @@ }, "supportedVersion": { "title": "A munkaterület verziója nem támogatott" + }, + "uiOverride": { + "title": "Felhasználói felület előnézetének betöltése", + "message": "Másik webes felületet tölt be ehhez: „{{- host}}”?", + "detail": "A felület innen töltődik be: {{- bundle}}, és a munkameneteddel fut ezen a szerveren. Csak akkor folytasd, ha megbízol ebben a buildben. A visszatéréshez használd a(z) Nézet > Szerver felületének visszaállítása menüpontot, vagy indítsd újra az alkalmazást.", + "load": "Felület betöltése", + "cancel": "Mégse", + "developerModeRequired": "Kapcsold be a(z) Súgó > Fejlesztői mód menüpontot a felületelőnézetek betöltéséhez.", + "failed": "A felület előnézetét nem sikerült betölteni." } }, "documentViewer": { @@ -232,7 +241,8 @@ "certificates": "Tanúsítványok", "developer": "Fejlesztő", "sections": { - "logging": "Naplózás" + "logging": "Naplózás", + "uiPreview": "Webes felület előnézete" }, "options": { "report": { @@ -334,6 +344,18 @@ "verboseOutlookLogging": { "title": "Részletes Outlook naptár naplózás", "description": "Részletes Exchange/NTLM hibakeresési naplók engedélyezése az Outlook naptár integrációs problémáinak elhárításához." + }, + "uiPreview": { + "title": "Webes felület buildjének betöltése", + "description": "A Rocket.Chat webes kliens egy buildjét futtatja a munkaterület saját felülete helyett, a munkamenet megtartásával. Adj meg egy Rocket.Chat PR-számot (a ghcr.io-ról töltődik le) vagy egy kiszolgált csomag URL-jét.", + "placeholder": "42364 vagy http://127.0.0.1:4173/", + "load": "Betöltés", + "restore": "Szerver felületének visszaállítása", + "active": "Aktív ezen a munkaterületen: {{- label}}", + "loading": "Letöltés és alkalmazás…", + "failed": "Nem sikerült betölteni: {{- message}}", + "cancelled": "Megszakítva.", + "inactive": "A szerver felülete van használatban." } } }, @@ -401,7 +423,8 @@ "windowMenu": "&Ablak", "zoomIn": "Nagyítás", "zoomOut": "Kicsinyítés", - "simulateDisconnected": "Kapcsolat megszakadásának szimulálása" + "simulateDisconnected": "Kapcsolat megszakadásának szimulálása", + "restoreServerUi": "Szerver felületének visszaállítása" }, "loadingError": { "title": "Kiszolgáló betöltése sikertelen", @@ -493,7 +516,8 @@ "canceled": "Megszakítva", "failed": "Sikertelen", "dismiss": "Bezárás" - } + }, + "uiPreview": "Felület előnézete: {{- label}}" }, "touchBar": { "formatting": "Formázás", diff --git a/src/i18n/it-IT.i18n.json b/src/i18n/it-IT.i18n.json index cea63ec77e..4c606b104a 100644 --- a/src/i18n/it-IT.i18n.json +++ b/src/i18n/it-IT.i18n.json @@ -31,7 +31,22 @@ "menuBarUnreadCount": { "title": "Numero di non letti nella barra dei menu", "description": "Mostra il numero di menzioni non lette accanto all'icona della barra dei menu." + }, + "uiPreview": { + "title": "Carica una build dell'interfaccia web", + "description": "Esegue una build del client web di Rocket.Chat al posto dell'interfaccia di un workspace, mantenendo la tua sessione. Inserisci il numero di una PR di Rocket.Chat (scaricata da ghcr.io) o l'URL di un bundle servito.", + "placeholder": "42364 o http://127.0.0.1:4173/", + "load": "Carica", + "restore": "Ripristina l'interfaccia del server", + "active": "Attiva su questo workspace: {{- label}}", + "loading": "Download e applicazione in corso…", + "failed": "Impossibile caricare: {{- message}}", + "cancelled": "Annullato.", + "inactive": "In uso l'interfaccia del server." } + }, + "sections": { + "uiPreview": "Anteprima dell'interfaccia web" } }, "tabBar": { @@ -70,7 +85,8 @@ "canceled": "Annullato", "failed": "Non riuscito", "dismiss": "Chiudi" - } + }, + "uiPreview": "Anteprima dell'interfaccia: {{- label}}" }, "serverInfo": { "title": "Informazioni sul Server", @@ -100,7 +116,8 @@ }, "menus": { "simulateDisconnected": "Simula disconnessione", - "resetWindowBounds": "Ripristina dimensioni e posizioni delle finestre" + "resetWindowBounds": "Ripristina dimensioni e posizioni delle finestre", + "restoreServerUi": "Ripristina l'interfaccia del server" }, "tray": { "presence": { @@ -113,5 +130,16 @@ "signIn": "Accedi a {{- workspace}}", "addWorkspace": "Aggiungi area di lavoro" } + }, + "dialog": { + "uiOverride": { + "title": "Carica anteprima dell'interfaccia", + "message": "Caricare un'interfaccia web diversa per \"{{- host}}\"?", + "detail": "L'interfaccia verrà caricata da {{- bundle}} e verrà eseguita con la tua sessione su questo server. Continua solo se ti fidi di questa build. Usa View > Ripristina l'interfaccia del server o riavvia l'app per tornare indietro.", + "load": "Carica interfaccia", + "cancel": "Annulla", + "developerModeRequired": "Attiva Help > Developer Mode per caricare anteprime dell'interfaccia.", + "failed": "Impossibile caricare l'anteprima dell'interfaccia." + } } } diff --git a/src/i18n/ja.i18n.json b/src/i18n/ja.i18n.json index a9292a3037..c0fec07ecb 100644 --- a/src/i18n/ja.i18n.json +++ b/src/i18n/ja.i18n.json @@ -94,6 +94,15 @@ "answerCall": "通話に応答する", "recordMessage": "メッセージを録音する" } + }, + "uiOverride": { + "title": "UI プレビューを読み込む", + "message": "「{{- host}}」に別の Web UI を読み込みますか?", + "detail": "UI は {{- bundle}} から読み込まれ、このサーバー上のあなたのセッションで実行されます。このビルドを信頼できる場合のみ続行してください。元に戻すには 表示 > サーバーの UI に戻す を使うか、アプリを再起動してください。", + "load": "UI を読み込む", + "cancel": "キャンセル", + "developerModeRequired": "UI プレビューを読み込むには ヘルプ > 開発者モード を有効にしてください。", + "failed": "UI プレビューを読み込めませんでした。" } }, "documentViewer": { @@ -138,7 +147,22 @@ "menuBarUnreadCount": { "title": "メニューバーに未読数を表示", "description": "メニューバーアイコンの横に未読メンションの数を表示します。" + }, + "uiPreview": { + "title": "Web UI のビルドを読み込む", + "description": "セッションを維持したまま、ワークスペース本来の UI の代わりに Rocket.Chat Web クライアントのビルドを実行します。Rocket.Chat の PR 番号(ghcr.io から取得)または配信中のバンドルの URL を入力してください。", + "placeholder": "42364 または http://127.0.0.1:4173/", + "load": "読み込む", + "restore": "サーバーの UI に戻す", + "active": "このワークスペースで有効: {{- label}}", + "loading": "ダウンロードして適用しています…", + "failed": "読み込めませんでした: {{- message}}", + "cancelled": "キャンセルしました。", + "inactive": "サーバーの UI を使用しています。" } + }, + "sections": { + "uiPreview": "Web UI プレビュー" } }, "error": { @@ -191,7 +215,8 @@ "windowMenu": "ウィンドウ (&W)", "zoomIn": "拡大", "zoomOut": "縮小", - "simulateDisconnected": "切断をシミュレート" + "simulateDisconnected": "切断をシミュレート", + "restoreServerUi": "サーバーの UI に戻す" }, "loadingError": { "title": "サーバーの読み込みに失敗しました", @@ -256,7 +281,8 @@ "canceled": "キャンセル済み", "failed": "失敗", "dismiss": "閉じる" - } + }, + "uiPreview": "UI プレビュー: {{- label}}" }, "touchBar": { "formatting": "書式", diff --git a/src/i18n/nb-NO.i18n.json b/src/i18n/nb-NO.i18n.json index f95494f643..d815d37034 100644 --- a/src/i18n/nb-NO.i18n.json +++ b/src/i18n/nb-NO.i18n.json @@ -31,7 +31,22 @@ "menuBarUnreadCount": { "title": "Antall uleste i menylinjen", "description": "Vis antall uleste omtaler ved siden av ikonet i menylinjen." + }, + "uiPreview": { + "title": "Last inn en build av nettgrensesnittet", + "description": "Kjører en build av Rocket.Chat-nettklienten i stedet for arbeidsområdets eget grensesnitt, og beholder økten din. Skriv inn et PR-nummer fra Rocket.Chat (hentes fra ghcr.io) eller URL-en til en servert pakke.", + "placeholder": "42364 eller http://127.0.0.1:4173/", + "load": "Last inn", + "restore": "Gjenopprett serverens grensesnitt", + "active": "Aktiv i dette arbeidsområdet: {{- label}}", + "loading": "Laster ned og tar i bruk…", + "failed": "Kunne ikke laste inn: {{- message}}", + "cancelled": "Avbrutt.", + "inactive": "Bruker serverens grensesnitt." } + }, + "sections": { + "uiPreview": "Forhåndsvisning av nettgrensesnitt" } }, "serverInfo": { @@ -62,7 +77,8 @@ }, "menus": { "simulateDisconnected": "Simuler frakobling", - "resetWindowBounds": "Tilbakestill vindusstørrelser og -posisjoner" + "resetWindowBounds": "Tilbakestill vindusstørrelser og -posisjoner", + "restoreServerUi": "Gjenopprett serverens grensesnitt" }, "tray": { "presence": { @@ -75,5 +91,19 @@ "signIn": "Logg på {{- workspace}}", "addWorkspace": "Legg til arbeidsområde" } + }, + "dialog": { + "uiOverride": { + "title": "Last inn forhåndsvisning av grensesnitt", + "message": "Laste inn et annet nettgrensesnitt for «{{- host}}»?", + "detail": "Grensesnittet lastes inn fra {{- bundle}} og kjører med økten din på denne serveren. Fortsett bare hvis du stoler på denne builden. Bruk View > Gjenopprett serverens grensesnitt eller start appen på nytt for å gå tilbake.", + "load": "Last inn grensesnitt", + "cancel": "Avbryt", + "developerModeRequired": "Slå på Help > Developer Mode for å laste inn forhåndsvisninger av grensesnitt.", + "failed": "Forhåndsvisningen av grensesnittet kunne ikke lastes inn." + } + }, + "tabBar": { + "uiPreview": "Forhåndsvisning av grensesnitt: {{- label}}" } } diff --git a/src/i18n/nn.i18n.json b/src/i18n/nn.i18n.json index e43dc763b5..2141707061 100644 --- a/src/i18n/nn.i18n.json +++ b/src/i18n/nn.i18n.json @@ -31,7 +31,22 @@ "menuBarUnreadCount": { "title": "Tal på ulesne i menylinja", "description": "Vis talet på ulesne omtalar ved sida av ikonet i menylinja." + }, + "uiPreview": { + "title": "Last inn ein build av nettgrensesnittet", + "description": "Køyrer ein build av Rocket.Chat-nettklienten i staden for det eigne grensesnittet til arbeidsområdet, og held på økta di. Skriv inn eit PR-nummer frå Rocket.Chat (henta frå ghcr.io) eller URL-en til ein servert pakke.", + "placeholder": "42364 eller http://127.0.0.1:4173/", + "load": "Last inn", + "restore": "Gjenopprett grensesnittet til tenaren", + "active": "Aktiv i dette arbeidsområdet: {{- label}}", + "loading": "Lastar ned og tek i bruk…", + "failed": "Kunne ikkje laste inn: {{- message}}", + "cancelled": "Avbrote.", + "inactive": "Brukar grensesnittet til tenaren." } + }, + "sections": { + "uiPreview": "Førehandsvising av nettgrensesnitt" } }, "serverInfo": { @@ -62,7 +77,8 @@ }, "menus": { "simulateDisconnected": "Simuler fråkopling", - "resetWindowBounds": "Nullstill vindaugsstorleikar og -posisjonar" + "resetWindowBounds": "Nullstill vindaugsstorleikar og -posisjonar", + "restoreServerUi": "Gjenopprett grensesnittet til tenaren" }, "tray": { "presence": { @@ -75,5 +91,19 @@ "signIn": "Logg på {{- workspace}}", "addWorkspace": "Legg til arbeidsområde" } + }, + "dialog": { + "uiOverride": { + "title": "Last inn førehandsvising av grensesnitt", + "message": "Laste inn eit anna nettgrensesnitt for «{{- host}}»?", + "detail": "Grensesnittet vert lasta inn frå {{- bundle}} og køyrer med økta di på denne tenaren. Hald berre fram om du stoler på denne builden. Bruk View > Gjenopprett grensesnittet til tenaren eller start appen på nytt for å gå tilbake.", + "load": "Last inn grensesnitt", + "cancel": "Avbryt", + "developerModeRequired": "Slå på Help > Developer Mode for å laste inn førehandsvisingar av grensesnitt.", + "failed": "Førehandsvisinga av grensesnittet kunne ikkje lastast inn." + } + }, + "tabBar": { + "uiPreview": "Førehandsvising av grensesnitt: {{- label}}" } } diff --git a/src/i18n/no.i18n.json b/src/i18n/no.i18n.json index 525acb06a6..4a2ef43cc5 100644 --- a/src/i18n/no.i18n.json +++ b/src/i18n/no.i18n.json @@ -164,6 +164,15 @@ "detail": "Denne handlingen kan ikke angres. Alle gjeldende loggoppføringer vil bli slettet permanent.", "yes": "Klar", "cancel": "Kansellere" + }, + "uiOverride": { + "title": "Last inn forhåndsvisning av grensesnitt", + "message": "Laste inn et annet nettgrensesnitt for «{{- host}}»?", + "detail": "Grensesnittet lastes inn fra {{- bundle}} og kjører med økten din på denne serveren. Fortsett bare hvis du stoler på denne builden. Bruk Visning > Gjenopprett serverens grensesnitt eller start appen på nytt for å gå tilbake.", + "load": "Last inn grensesnitt", + "cancel": "Avbryt", + "developerModeRequired": "Slå på Hjelp > Utviklermodus for å laste inn forhåndsvisninger av grensesnitt.", + "failed": "Forhåndsvisningen av grensesnittet kunne ikke lastes inn." } }, "documentViewer": { @@ -239,7 +248,8 @@ "certificates": "Sertifikater", "developer": "Utvikler", "sections": { - "logging": "Logging" + "logging": "Logging", + "uiPreview": "Forhåndsvisning av nettgrensesnitt" }, "options": { "report": { @@ -348,6 +358,18 @@ }, "detailedEventsLogging": { "title": "Detaljert hendelseslogging" + }, + "uiPreview": { + "title": "Last inn en build av nettgrensesnittet", + "description": "Kjører en build av Rocket.Chat-nettklienten i stedet for arbeidsområdets eget grensesnitt, og beholder økten din. Skriv inn et PR-nummer fra Rocket.Chat (hentes fra ghcr.io) eller URL-en til en servert pakke.", + "placeholder": "42364 eller http://127.0.0.1:4173/", + "load": "Last inn", + "restore": "Gjenopprett serverens grensesnitt", + "active": "Aktiv i dette arbeidsområdet: {{- label}}", + "loading": "Laster ned og tar i bruk…", + "failed": "Kunne ikke laste inn: {{- message}}", + "cancelled": "Avbrutt.", + "inactive": "Bruker serverens grensesnitt." } } }, @@ -414,7 +436,8 @@ "windowMenu": "Vindu", "zoomIn": "Zoom inn", "zoomOut": "Zoom ut", - "simulateDisconnected": "Simuler frakobling" + "simulateDisconnected": "Simuler frakobling", + "restoreServerUi": "Gjenopprett serverens grensesnitt" }, "loadingError": { "title": "Serveren kunne ikke lastes inn", @@ -506,7 +529,8 @@ "canceled": "Kansellert", "failed": "Mislyktes", "dismiss": "Lukk" - } + }, + "uiPreview": "Forhåndsvisning av grensesnitt: {{- label}}" }, "touchBar": { "formatting": "Formatering", diff --git a/src/i18n/pl.i18n.json b/src/i18n/pl.i18n.json index 38c89d13df..148f4a9929 100644 --- a/src/i18n/pl.i18n.json +++ b/src/i18n/pl.i18n.json @@ -106,6 +106,15 @@ "answerCall": "odbierania połączeń", "recordMessage": "nagrywania wiadomości" } + }, + "uiOverride": { + "title": "Wczytaj podgląd interfejsu", + "message": "Wczytać inny interfejs webowy dla „{{- host}}”?", + "detail": "Interfejs zostanie wczytany z {{- bundle}} i będzie działał z Twoją sesją na tym serwerze. Kontynuuj tylko, jeśli ufasz tej kompilacji. Aby wrócić, użyj Widok > Przywróć interfejs serwera lub uruchom aplikację ponownie.", + "load": "Wczytaj interfejs", + "cancel": "Anuluj", + "developerModeRequired": "Włącz Pomoc > Tryb programisty, aby wczytywać podglądy interfejsu.", + "failed": "Nie udało się wczytać podglądu interfejsu." } }, "documentViewer": { @@ -163,7 +172,22 @@ "auto": "Zgodnie z systemem", "light": "Jasny", "dark": "Ciemny" + }, + "uiPreview": { + "title": "Wczytaj kompilację interfejsu webowego", + "description": "Uruchamia kompilację klienta webowego Rocket.Chat zamiast własnego interfejsu przestrzeni roboczej, zachowując Twoją sesję. Wpisz numer PR Rocket.Chat (pobierany z ghcr.io) lub adres URL udostępnianego pakietu.", + "placeholder": "42364 lub http://127.0.0.1:4173/", + "load": "Wczytaj", + "restore": "Przywróć interfejs serwera", + "active": "Aktywny w tej przestrzeni roboczej: {{- label}}", + "loading": "Pobieranie i stosowanie…", + "failed": "Nie udało się wczytać: {{- message}}", + "cancelled": "Anulowano.", + "inactive": "Używany jest interfejs serwera." } + }, + "sections": { + "uiPreview": "Podgląd interfejsu webowego" } }, "error": { @@ -220,7 +244,8 @@ "windowMenu": "&Okno", "zoomIn": "Przybliż", "zoomOut": "Oddal", - "simulateDisconnected": "Symuluj rozłączenie" + "simulateDisconnected": "Symuluj rozłączenie", + "restoreServerUi": "Przywróć interfejs serwera" }, "loadingError": { "title": "Błąd ładowania serwera", @@ -291,7 +316,8 @@ "canceled": "Anulowano", "failed": "Nie udało się", "dismiss": "Zamknij" - } + }, + "uiPreview": "Podgląd interfejsu: {{- label}}" }, "touchBar": { "formatting": "Formatowanie", diff --git a/src/i18n/ru.i18n.json b/src/i18n/ru.i18n.json index 786d153f11..e05758e12d 100644 --- a/src/i18n/ru.i18n.json +++ b/src/i18n/ru.i18n.json @@ -130,6 +130,15 @@ "answerCall": "отвечать на звонки", "recordMessage": "записывать сообщения" } + }, + "uiOverride": { + "title": "Загрузить предпросмотр интерфейса", + "message": "Загрузить другой веб-интерфейс для «{{- host}}»?", + "detail": "Интерфейс будет загружен из {{- bundle}} и будет работать с вашей сессией на этом сервере. Продолжайте, только если доверяете этой сборке. Чтобы вернуться, используйте Вид > Вернуть интерфейс сервера или перезапустите приложение.", + "load": "Загрузить интерфейс", + "cancel": "Отмена", + "developerModeRequired": "Включите Справка > Режим разработчика, чтобы загружать предпросмотры интерфейса.", + "failed": "Не удалось загрузить предпросмотр интерфейса." } }, "documentViewer": { @@ -295,7 +304,22 @@ "auto": "Следовать системе", "light": "Светлая", "dark": "Тёмная" + }, + "uiPreview": { + "title": "Загрузить сборку веб-интерфейса", + "description": "Запускает сборку веб-клиента Rocket.Chat вместо собственного интерфейса рабочего пространства с сохранением вашей сессии. Введите номер PR Rocket.Chat (загружается из ghcr.io) или URL раздаваемого бандла.", + "placeholder": "42364 или http://127.0.0.1:4173/", + "load": "Загрузить", + "restore": "Вернуть интерфейс сервера", + "active": "Активен в этом рабочем пространстве: {{- label}}", + "loading": "Загрузка и применение…", + "failed": "Не удалось загрузить: {{- message}}", + "cancelled": "Отменено.", + "inactive": "Используется интерфейс сервера." } + }, + "sections": { + "uiPreview": "Предпросмотр веб-интерфейса" } }, "error": { @@ -356,7 +380,8 @@ "windowMenu": "&Окна", "zoomIn": "Увеличить", "zoomOut": "Уменьшить", - "simulateDisconnected": "Симулировать отключение" + "simulateDisconnected": "Симулировать отключение", + "restoreServerUi": "Вернуть интерфейс сервера" }, "loadingError": { "title": "Не удалось загрузить сервер", @@ -431,7 +456,8 @@ "canceled": "Отменено", "failed": "Не удалось", "dismiss": "Закрыть" - } + }, + "uiPreview": "Предпросмотр интерфейса: {{- label}}" }, "touchBar": { "formatting": "Форматирование", diff --git a/src/i18n/se.i18n.json b/src/i18n/se.i18n.json index 1e0d1b096b..b10e5cb054 100644 --- a/src/i18n/se.i18n.json +++ b/src/i18n/se.i18n.json @@ -31,7 +31,22 @@ "menuBarUnreadCount": { "title": "Lohkkekeahtes dieđáhusaid lohku fálloholggas", "description": "Čájet lohkkekeahtes namuhemiid logu fálloholgga govaža bálddas." + }, + "uiPreview": { + "title": "Viečča web-geavaheaddjilaktta builda", + "description": "Vuodjá Rocket.Chat web-klienta builda bargobáikki iežas geavaheaddjilavtta sajis, ja doallá du sešuvnna. Čále Rocket.Chat PR-nummira (viežžojuvvo ghcr.io:s) dahje bálvaluvvon páhka URL-čujuhusa.", + "placeholder": "42364 dahje http://127.0.0.1:4173/", + "load": "Viečča", + "restore": "Máhcat bálvvá geavaheaddjilaktta", + "active": "Aktiiva dán bargobáikkis: {{- label}}", + "loading": "Viežžá ja váldá atnui…", + "failed": "Ii sáhttán viežžat: {{- message}}", + "cancelled": "Gaskkalduhttojuvvon.", + "inactive": "Geavaha bálvvá geavaheaddjilaktta." } + }, + "sections": { + "uiPreview": "Web-geavaheaddjilakta-ovdačájáhus" } }, "serverInfo": { @@ -62,7 +77,8 @@ }, "menus": { "simulateDisconnected": "Simuler čađaheami", - "resetWindowBounds": "Máhcat lásiid sturrodagaid ja sajiid" + "resetWindowBounds": "Máhcat lásiid sturrodagaid ja sajiid", + "restoreServerUi": "Máhcat bálvvá geavaheaddjilaktta" }, "tray": { "presence": { @@ -75,5 +91,19 @@ "signIn": "Čálihuvvo {{- workspace}} bargobáikái", "addWorkspace": "Lasit bargobáikki" } + }, + "dialog": { + "uiOverride": { + "title": "Viečča geavaheaddjilaktta ovdačájáhusa", + "message": "Viežžat eará web-geavaheaddjilaktta \"{{- host}}\" várás?", + "detail": "Geavaheaddjilakta viežžojuvvo {{- bundle}} čujuhusas ja doaibmá du sešuvnnain dán bálvvás. Joatkke dušše jus luhtet dán buildii. Geavat View > Máhcat bálvvá geavaheaddjilaktta dahje álggat prográmma ođđasit máhccat.", + "load": "Viečča geavaheaddjilaktta", + "cancel": "Gaskkalduhte", + "developerModeRequired": "Doaimmat Help > Developer Mode vai sáhtát viežžat geavaheaddjilakta-ovdačájáhusaid.", + "failed": "Ii sáhttán viežžat geavaheaddjilakta-ovdačájáhusa." + } + }, + "tabBar": { + "uiPreview": "Geavaheaddjilakta-ovdačájáhus: {{- label}}" } } diff --git a/src/i18n/sv.i18n.json b/src/i18n/sv.i18n.json index b43470e2d0..a9e5bc8a86 100644 --- a/src/i18n/sv.i18n.json +++ b/src/i18n/sv.i18n.json @@ -157,6 +157,15 @@ }, "supportedVersion": { "title": "Versionen av arbetsytan stöds inte" + }, + "uiOverride": { + "title": "Läs in förhandsvisning av gränssnitt", + "message": "Läsa in ett annat webbgränssnitt för ”{{- host}}”?", + "detail": "Gränssnittet läses in från {{- bundle}} och körs med din session på den här servern. Fortsätt bara om du litar på det här bygget. Använd Visa > Återställ serverns gränssnitt eller starta om appen för att gå tillbaka.", + "load": "Läs in gränssnitt", + "cancel": "Avbryt", + "developerModeRequired": "Aktivera Hjälp > Utvecklarläge för att läsa in förhandsvisningar av gränssnitt.", + "failed": "Förhandsvisningen av gränssnittet kunde inte läsas in." } }, "documentViewer": { @@ -326,7 +335,22 @@ "auto": "Följ system", "light": "Ljus", "dark": "Mörk" + }, + "uiPreview": { + "title": "Läs in ett bygge av webbgränssnittet", + "description": "Kör ett bygge av Rocket.Chats webbklient i stället för arbetsytans eget gränssnitt och behåller din session. Ange ett PR-nummer från Rocket.Chat (hämtas från ghcr.io) eller URL:en till ett serverat paket.", + "placeholder": "42364 eller http://127.0.0.1:4173/", + "load": "Läs in", + "restore": "Återställ serverns gränssnitt", + "active": "Aktiv på den här arbetsytan: {{- label}}", + "loading": "Laddar ned och tillämpar…", + "failed": "Kunde inte läsa in: {{- message}}", + "cancelled": "Avbrutet.", + "inactive": "Serverns gränssnitt används." } + }, + "sections": { + "uiPreview": "Förhandsvisning av webbgränssnitt" } }, "error": { @@ -392,7 +416,8 @@ "windowMenu": "&Fönster", "zoomIn": "Zooma in", "zoomOut": "Zooma ut", - "simulateDisconnected": "Simulera frånkoppling" + "simulateDisconnected": "Simulera frånkoppling", + "restoreServerUi": "Återställ serverns gränssnitt" }, "loadingError": { "title": "Servern kunde inte laddas", @@ -484,7 +509,8 @@ "canceled": "Avbruten", "failed": "Misslyckades", "dismiss": "Stäng" - } + }, + "uiPreview": "Förhandsvisning av gränssnitt: {{- label}}" }, "touchBar": { "formatting": "Formatering", diff --git a/src/i18n/tr-TR.i18n.json b/src/i18n/tr-TR.i18n.json index 5a84e4c3a2..50981cf214 100644 --- a/src/i18n/tr-TR.i18n.json +++ b/src/i18n/tr-TR.i18n.json @@ -94,6 +94,15 @@ "answerCall": "aramaları yanıtlamak", "recordMessage": "mesaj kaydetmek" } + }, + "uiOverride": { + "title": "Arayüz önizlemesini yükle", + "message": "\"{{- host}}\" için farklı bir web arayüzü yüklensin mi?", + "detail": "Arayüz {{- bundle}} adresinden yüklenecek ve bu sunucudaki oturumunuzla çalışacak. Yalnızca bu derlemeye güveniyorsanız devam edin. Geri dönmek için Görünüm > Sunucu arayüzünü geri yükle seçeneğini kullanın veya uygulamayı yeniden başlatın.", + "load": "Arayüzü yükle", + "cancel": "İptal", + "developerModeRequired": "Arayüz önizlemelerini yüklemek için Yardım > Geliştirici Modu seçeneğini açın.", + "failed": "Arayüz önizlemesi yüklenemedi." } }, "documentViewer": { @@ -151,7 +160,22 @@ "auto": "Sistemi takip et", "light": "Açık", "dark": "Koyu" + }, + "uiPreview": { + "title": "Bir web arayüzü derlemesi yükle", + "description": "Oturumunuzu koruyarak bir çalışma alanının kendi arayüzü yerine Rocket.Chat web istemcisinin bir derlemesini çalıştırır. Bir Rocket.Chat PR numarası (ghcr.io üzerinden çekilir) veya sunulan bir paketin URL adresini girin.", + "placeholder": "42364 veya http://127.0.0.1:4173/", + "load": "Yükle", + "restore": "Sunucu arayüzünü geri yükle", + "active": "Bu çalışma alanında etkin: {{- label}}", + "loading": "İndiriliyor ve uygulanıyor…", + "failed": "Yüklenemedi: {{- message}}", + "cancelled": "İptal edildi.", + "inactive": "Sunucunun arayüzü kullanılıyor." } + }, + "sections": { + "uiPreview": "Web arayüzü önizlemesi" } }, "error": { @@ -204,7 +228,8 @@ "windowMenu": "&Pencereler", "zoomIn": "Yakınlaştır", "zoomOut": "Uzaklaştır", - "simulateDisconnected": "Bağlantı Kesilmesini Simüle Et" + "simulateDisconnected": "Bağlantı Kesilmesini Simüle Et", + "restoreServerUi": "Sunucu arayüzünü geri yükle" }, "loadingError": { "title": "Sunucu yüklenirken hata oluştu", @@ -275,7 +300,8 @@ "canceled": "İptal", "failed": "Başarısız", "dismiss": "Kapat" - } + }, + "uiPreview": "Arayüz önizlemesi: {{- label}}" }, "touchBar": { "formatting": "Bçimlendiriliyor", diff --git a/src/i18n/uk-UA.i18n.json b/src/i18n/uk-UA.i18n.json index 5685bd0fc9..91c36901d9 100644 --- a/src/i18n/uk-UA.i18n.json +++ b/src/i18n/uk-UA.i18n.json @@ -76,6 +76,15 @@ "announcement": "Вибрати сертифікат", "select": "Вибрати", "validDates": "Дійсний з {{-validStart,}} по {{-validExpiry,}}" + }, + "uiOverride": { + "title": "Завантажити попередній перегляд інтерфейсу", + "message": "Завантажити інший вебінтерфейс для «{{- host}}»?", + "detail": "Інтерфейс буде завантажено з {{- bundle}}, і він працюватиме з вашим сеансом на цьому сервері. Продовжуйте, лише якщо довіряєте цій збірці. Щоб повернутися, скористайтеся Вигляд > Повернути інтерфейс сервера або перезапустіть застосунок.", + "load": "Завантажити інтерфейс", + "cancel": "Скасувати", + "developerModeRequired": "Увімкніть Довідка > Режим розробника, щоб завантажувати попередні перегляди інтерфейсу.", + "failed": "Не вдалося завантажити попередній перегляд інтерфейсу." } }, "documentViewer": { @@ -133,7 +142,22 @@ "auto": "Слідувати системі", "light": "Світла", "dark": "Темна" + }, + "uiPreview": { + "title": "Завантажити збірку вебінтерфейсу", + "description": "Запускає збірку вебклієнта Rocket.Chat замість власного інтерфейсу робочого простору, зберігаючи ваш сеанс. Введіть номер PR Rocket.Chat (завантажується з ghcr.io) або URL-адресу пакета, що роздається.", + "placeholder": "42364 або http://127.0.0.1:4173/", + "load": "Завантажити", + "restore": "Повернути інтерфейс сервера", + "active": "Активний у цьому робочому просторі: {{- label}}", + "loading": "Завантаження й застосування…", + "failed": "Не вдалося завантажити: {{- message}}", + "cancelled": "Скасовано.", + "inactive": "Використовується інтерфейс сервера." } + }, + "sections": { + "uiPreview": "Попередній перегляд вебінтерфейсу" } }, "error": { @@ -189,7 +213,8 @@ "windowMenu": "В&ікно", "zoomIn": "Збільшити", "zoomOut": "Зменшити", - "simulateDisconnected": "Симулювати відключення" + "simulateDisconnected": "Симулювати відключення", + "restoreServerUi": "Повернути інтерфейс сервера" }, "loadingError": { "title": "Не вдалося завантажити сервер", @@ -260,7 +285,8 @@ "canceled": "Скасовано", "failed": "Не вдалося", "dismiss": "Закрити" - } + }, + "uiPreview": "Попередній перегляд інтерфейсу: {{- label}}" }, "touchBar": { "formatting": "Форматування", diff --git a/src/i18n/zh-CN.i18n.json b/src/i18n/zh-CN.i18n.json index 8b88617188..3dc31e93e3 100644 --- a/src/i18n/zh-CN.i18n.json +++ b/src/i18n/zh-CN.i18n.json @@ -95,6 +95,15 @@ "answerCall": "接听电话", "recordMessage": "录制消息" } + }, + "uiOverride": { + "title": "加载界面预览", + "message": "要为“{{- host}}”加载其他 Web 界面吗?", + "detail": "界面将从 {{- bundle}} 加载,并以你在此服务器上的会话运行。仅在你信任此构建时继续。使用 显示 > 恢复服务器界面 或重启应用即可恢复。", + "load": "加载界面", + "cancel": "取消", + "developerModeRequired": "请启用 帮助 > 开发者模式 以加载界面预览。", + "failed": "无法加载界面预览。" } }, "documentViewer": { @@ -190,7 +199,22 @@ "menuBarUnreadCount": { "title": "菜单栏中的未读数", "description": "在菜单栏图标旁显示未读提及的数量。" + }, + "uiPreview": { + "title": "加载 Web 界面构建", + "description": "在保留会话的情况下,用 Rocket.Chat Web 客户端的某个构建替代工作区自身的界面。请输入 Rocket.Chat PR 编号(从 ghcr.io 拉取)或已托管包的 URL。", + "placeholder": "42364 或 http://127.0.0.1:4173/", + "load": "加载", + "restore": "恢复服务器界面", + "active": "已在此工作区启用:{{- label}}", + "loading": "正在下载并应用…", + "failed": "无法加载:{{- message}}", + "cancelled": "已取消。", + "inactive": "正在使用服务器的界面。" } + }, + "sections": { + "uiPreview": "Web 界面预览" } }, "error": { @@ -246,7 +270,8 @@ "windowMenu": "窗口 (&W)", "zoomIn": "放大", "zoomOut": "缩小", - "simulateDisconnected": "模拟断开连接" + "simulateDisconnected": "模拟断开连接", + "restoreServerUi": "恢复服务器界面" }, "loadingError": { "title": "服务器载入失败", @@ -315,7 +340,8 @@ "canceled": "已取消", "failed": "失败", "dismiss": "关闭" - } + }, + "uiPreview": "界面预览:{{- label}}" }, "touchBar": { "formatting": "格式化", diff --git a/src/i18n/zh-TW.i18n.json b/src/i18n/zh-TW.i18n.json index 1d9e4cf0ce..7ad2853e33 100644 --- a/src/i18n/zh-TW.i18n.json +++ b/src/i18n/zh-TW.i18n.json @@ -73,6 +73,15 @@ "title": "忽略更新", "message": "我們將會在下次有新的更新版本的時候通知您\n如果您改變主意想安裝此次更新,您可以從「關於」的選單中檢視更新", "ok": "好" + }, + "uiOverride": { + "title": "載入介面預覽", + "message": "要為「{{- host}}」載入其他 Web 介面嗎?", + "detail": "介面將從 {{- bundle}} 載入,並以你在此伺服器上的工作階段執行。僅在你信任此建置時繼續。使用 檢視 > 還原伺服器介面 或重新啟動應用程式即可還原。", + "load": "載入介面", + "cancel": "取消", + "developerModeRequired": "請啟用 幫助 > 開發者模式 以載入介面預覽。", + "failed": "無法載入介面預覽。" } }, "documentViewer": { @@ -130,7 +139,22 @@ "auto": "跟隨系統", "light": "淺色", "dark": "深色" + }, + "uiPreview": { + "title": "載入 Web 介面建置", + "description": "在保留工作階段的情況下,以 Rocket.Chat Web 用戶端的某個建置取代工作區本身的介面。請輸入 Rocket.Chat PR 編號(從 ghcr.io 拉取)或已提供之套件的 URL。", + "placeholder": "42364 或 http://127.0.0.1:4173/", + "load": "載入", + "restore": "還原伺服器介面", + "active": "已在此工作區啟用:{{- label}}", + "loading": "正在下載並套用…", + "failed": "無法載入:{{- message}}", + "cancelled": "已取消。", + "inactive": "正在使用伺服器的介面。" } + }, + "sections": { + "uiPreview": "Web 介面預覽" } }, "error": { @@ -183,7 +207,8 @@ "windowMenu": "視窗 (&W)", "zoomIn": "放大", "zoomOut": "縮小", - "simulateDisconnected": "模擬中斷連線" + "simulateDisconnected": "模擬中斷連線", + "restoreServerUi": "還原伺服器介面" }, "loadingError": { "title": "伺服器載入失敗", @@ -233,7 +258,8 @@ "canceled": "已取消", "failed": "失敗", "dismiss": "關閉" - } + }, + "uiPreview": "介面預覽:{{- label}}" }, "touchBar": { "formatting": "Formatting", diff --git a/src/i18n/zh.i18n.json b/src/i18n/zh.i18n.json index 3d67e2e3d7..4c267796af 100644 --- a/src/i18n/zh.i18n.json +++ b/src/i18n/zh.i18n.json @@ -31,7 +31,22 @@ "menuBarUnreadCount": { "title": "菜单栏中的未读数", "description": "在菜单栏图标旁显示未读提及的数量。" + }, + "uiPreview": { + "title": "加载 Web 界面构建", + "description": "在保留会话的情况下,用 Rocket.Chat Web 客户端的某个构建替代工作区自身的界面。请输入 Rocket.Chat PR 编号(从 ghcr.io 拉取)或已托管包的 URL。", + "placeholder": "42364 或 http://127.0.0.1:4173/", + "load": "加载", + "restore": "恢复服务器界面", + "active": "已在此工作区启用:{{- label}}", + "loading": "正在下载并应用…", + "failed": "无法加载:{{- message}}", + "cancelled": "已取消。", + "inactive": "正在使用服务器的界面。" } + }, + "sections": { + "uiPreview": "Web 界面预览" } }, "serverInfo": { @@ -62,7 +77,8 @@ }, "menus": { "simulateDisconnected": "模拟断开连接", - "resetWindowBounds": "重置窗口大小和位置" + "resetWindowBounds": "重置窗口大小和位置", + "restoreServerUi": "恢复服务器界面" }, "tray": { "presence": { @@ -75,5 +91,19 @@ "signIn": "登录 {{- workspace}}", "addWorkspace": "添加工作区" } + }, + "dialog": { + "uiOverride": { + "title": "加载界面预览", + "message": "要为“{{- host}}”加载其他 Web 界面吗?", + "detail": "界面将从 {{- bundle}} 加载,并以你在此服务器上的会话运行。仅在你信任此构建时继续。使用 View > 恢复服务器界面 或重启应用即可恢复。", + "load": "加载界面", + "cancel": "取消", + "developerModeRequired": "请启用 Help > Developer Mode 以加载界面预览。", + "failed": "无法加载界面预览。" + } + }, + "tabBar": { + "uiPreview": "界面预览:{{- label}}" } }