mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
fix(apps): per-session desktop button opens that session's machine (#123412)
* fix(apps): open session desktop on its machine * fix(ui): scope the desktop session lookup to the key's own agent `sessions.list` has no exact-key filter, so the viewer resolves a `session=` parameter by searching for the key and matching it exactly in the response. A key that prefixes longer ones — `agent:main:main` alongside user-named sessions that start the same way — could push the exact row outside a five-row page and silently fall back to the picker. Session keys encode their agent and the list API accepts `agentId`, so scope the search to that agent and widen the page. * refactor(ui): stop parking a session key in the desktop environment id Document-mode inventory failures stashed the requested session key in `environmentId` purely so the Retry button's non-null guard would pass, even though document-mode retry refreshes the inventory and never reads the value. A session key only names a machine once the inventory loads, so it now stays out of `environmentId`, and the retry branch that ignores it runs before the guard. Adds E2E coverage for recovering a session-preselected desktop. * fix(ui): resolve the desktop session with an exact-key lookup The session-preselect path searched `sessions.list` and scoped the search to the key's own agent, but a bounded search cannot rule a key out: 25 newer same-agent sessions sharing the requested key's prefix would push the exact row off the page, and the viewer would report the source as unavailable for a session that exists. `sessions.describe` is the exact-key operation and already projects placement, so the panel calls it directly and the app-root resolver plumbing goes away with it. * refactor(ui): move the desktop document keyboard bridge into its own controller `desktop-panel.ts` crossed the 700-line cap. The mobile keyboard bridge — the padded sentinel, the value diffing that turns composed input into backspaces and text, and the field focus/reset helpers — is a self-contained concern, so it moves into a `DesktopMobileKeyboard` controller alongside the existing fullscreen controller instead of taking a `max-lines` suppression. * test(ios): drain text fields instead of assuming a delete burst lands `testReleaseChatScreenshot` typed a 5-character probe, sent 5 deletes in one `typeText`, then asserted the field was empty. CI dropped one synthetic keystroke under simulator load and the assertion failed with a leftover "f". XCUITest makes no lossless-burst guarantee, so clearing now re-sends against whatever the field actually still holds, bounded. The two gateway-setup fields that overtyped through the same burst use the helper for the same reason. * fix(ui): keep the chat placement owner out of the startup chunk `desktop-document-mode.ts` is imported by bootstrap, so importing the chat placement owner from it pulled the chat page's dependency tree into the startup bundle and pushed startup JS past its gzip budget (331075 B against a 330507 B allowance). The route module now only parses the URL; resolving a session to its machine moves next to the lazily loaded desktop panel, which is the only caller. Startup JS is back to 329710 B.
This commit is contained in:
committed by
GitHub
parent
848a7e30b3
commit
e5fca42952
@@ -34,6 +34,7 @@ import androidx.core.net.toUri
|
||||
internal fun DesktopScreen(
|
||||
viewModel: MainViewModel,
|
||||
source: String? = null,
|
||||
session: String? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
@@ -72,10 +73,10 @@ internal fun DesktopScreen(
|
||||
val page = controlPage
|
||||
if (isConnected && page != null) {
|
||||
// GatewayControlPage equality includes credentials and the accepted TLS pin.
|
||||
key(page, source) {
|
||||
key(page, source, session) {
|
||||
ControlUiWebView(
|
||||
page = page,
|
||||
url = desktopUrl(baseUrl = page.baseUrl, source = source),
|
||||
url = desktopUrl(baseUrl = page.baseUrl, source = source, session = session),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -106,6 +107,7 @@ internal fun DesktopScreen(
|
||||
internal fun desktopUrl(
|
||||
baseUrl: String,
|
||||
source: String? = null,
|
||||
session: String? = null,
|
||||
): String {
|
||||
val baseUri = baseUrl.trimEnd('/').toUri()
|
||||
val routePath = "${baseUri.encodedPath.orEmpty().trimEnd('/')}/"
|
||||
@@ -117,5 +119,6 @@ internal fun desktopUrl(
|
||||
.fragment(null)
|
||||
.appendQueryParameter("view", "desktop")
|
||||
source?.let { builder.appendQueryParameter("source", it) }
|
||||
session?.let { builder.appendQueryParameter("session", it) }
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
@@ -48,9 +48,7 @@ internal fun SessionDashboardScreen(
|
||||
// The viewer replaces this screen in place rather than pushing a shell tab, so it must
|
||||
// claim System Back itself; the shell handler would otherwise pop the whole dashboard.
|
||||
BackHandler { showingDesktop = false }
|
||||
// Session summaries do not advertise an environment id, so the viewer opens
|
||||
// its source picker instead of guessing a gateway or node association.
|
||||
DesktopScreen(viewModel = viewModel, source = null, onBack = { showingDesktop = false })
|
||||
DesktopScreen(viewModel = viewModel, session = sessionKey, onBack = { showingDesktop = false })
|
||||
return
|
||||
}
|
||||
ClawScaffold(
|
||||
|
||||
@@ -34,4 +34,33 @@ class DesktopScreenTest {
|
||||
assertFalse(url.contains("token="))
|
||||
assertFalse(url.contains("password="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopUrlEncodesProvidedSession() {
|
||||
val url =
|
||||
desktopUrl(
|
||||
baseUrl = "https://gateway.example.com:8443/openclaw/",
|
||||
session = "agent:main:mobile session",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://gateway.example.com:8443/openclaw/?view=desktop&session=agent%3Amain%3Amobile%20session",
|
||||
url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopUrlRetainsExplicitSourceAlongsideSession() {
|
||||
val url =
|
||||
desktopUrl(
|
||||
baseUrl = "https://gateway.example.com:8443",
|
||||
source = "node:worker-1",
|
||||
session = "agent:main:mobile",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://gateway.example.com:8443/?view=desktop&source=node%3Aworker-1&session=agent%3Amain%3Amobile",
|
||||
url,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,7 @@ struct SessionDashboardScreen: View {
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: self.$showsDesktop) {
|
||||
// Session dashboard presentation currently carries only the session key,
|
||||
// so the desktop document mode owns source selection.
|
||||
DesktopHubScreen(source: nil, usesNativeNavigationChrome: true)
|
||||
DesktopHubScreen(session: self.sessionKey, usesNativeNavigationChrome: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,20 @@ import SwiftUI
|
||||
struct DesktopHubScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
let source: String?
|
||||
let session: String?
|
||||
let headerSidebarAction: OpenClawSidebarHeaderAction?
|
||||
let usesNativeNavigationChrome: Bool
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(
|
||||
source: String? = nil,
|
||||
session: String? = nil,
|
||||
headerSidebarAction: OpenClawSidebarHeaderAction? = nil,
|
||||
usesNativeNavigationChrome: Bool = false,
|
||||
gatewayAction: (() -> Void)? = nil)
|
||||
{
|
||||
self.source = source
|
||||
self.session = session
|
||||
self.headerSidebarAction = headerSidebarAction
|
||||
self.usesNativeNavigationChrome = usesNativeNavigationChrome
|
||||
self.gatewayAction = gatewayAction
|
||||
@@ -27,17 +30,19 @@ struct DesktopHubScreen: View {
|
||||
let storedOperatorToken = AuthenticatedControlUI.storedOperatorToken(config: config)
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
if let url = Self.desktopURL(config: config, source: self.source) {
|
||||
if let url = Self.desktopURL(config: config, source: self.source, session: self.session) {
|
||||
AuthenticatedControlUIWebView(
|
||||
url: url,
|
||||
authScript: Self.desktopAuthUserScript(
|
||||
config: config,
|
||||
source: self.source,
|
||||
session: self.session,
|
||||
storedOperatorToken: storedOperatorToken),
|
||||
tls: config?.tls)
|
||||
.id(Self.webContentIdentity(
|
||||
config: config,
|
||||
source: self.source,
|
||||
session: self.session,
|
||||
storedOperatorToken: storedOperatorToken))
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
} else {
|
||||
@@ -90,38 +95,52 @@ struct DesktopHubScreen: View {
|
||||
|
||||
/// Credentials never enter this URL; the document-start user script carries
|
||||
/// them through the Control UI's native-auth contract.
|
||||
static func desktopURL(config: GatewayConnectConfig?, source: String?) -> URL? {
|
||||
static func desktopURL(
|
||||
config: GatewayConnectConfig?,
|
||||
source: String?,
|
||||
session: String? = nil) -> URL?
|
||||
{
|
||||
var queryItems = [URLQueryItem(name: "view", value: "desktop")]
|
||||
if let source = self.normalizedSource(source) {
|
||||
queryItems.append(URLQueryItem(name: "source", value: source))
|
||||
}
|
||||
if let session = self.normalizedSource(session) {
|
||||
queryItems.append(URLQueryItem(name: "session", value: session))
|
||||
}
|
||||
return AuthenticatedControlUI.pageURL(
|
||||
config: config,
|
||||
path: "/",
|
||||
queryItems: queryItems)
|
||||
}
|
||||
|
||||
static func desktopAuthUserScript(config: GatewayConnectConfig?, source: String?) -> String? {
|
||||
static func desktopAuthUserScript(
|
||||
config: GatewayConnectConfig?,
|
||||
source: String?,
|
||||
session: String? = nil) -> String?
|
||||
{
|
||||
self.desktopAuthUserScript(
|
||||
config: config,
|
||||
source: source,
|
||||
session: session,
|
||||
storedOperatorToken: AuthenticatedControlUI.storedOperatorToken(config: config))
|
||||
}
|
||||
|
||||
static func desktopAuthUserScript(
|
||||
config: GatewayConnectConfig?,
|
||||
source: String?,
|
||||
session: String? = nil,
|
||||
storedOperatorToken: String?) -> String?
|
||||
{
|
||||
AuthenticatedControlUI.authUserScript(
|
||||
config: config,
|
||||
pageURL: self.desktopURL(config: config, source: source),
|
||||
pageURL: self.desktopURL(config: config, source: source, session: session),
|
||||
storedOperatorToken: storedOperatorToken)
|
||||
}
|
||||
|
||||
static func webContentIdentity(
|
||||
config: GatewayConnectConfig?,
|
||||
source: String?,
|
||||
session: String? = nil,
|
||||
storedOperatorToken: String?) -> Int
|
||||
{
|
||||
var hasher = Hasher()
|
||||
@@ -129,6 +148,7 @@ struct DesktopHubScreen: View {
|
||||
config: config,
|
||||
storedOperatorToken: storedOperatorToken))
|
||||
hasher.combine(self.normalizedSource(source))
|
||||
hasher.combine(self.normalizedSource(session))
|
||||
return hasher.finalize()
|
||||
}
|
||||
|
||||
|
||||
@@ -34,28 +34,42 @@ struct DesktopHubScreenTests {
|
||||
token: "secret-token",
|
||||
password: "secret-password")
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: nil)
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: nil, session: nil)
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=desktop")
|
||||
#expect(url?.absoluteString.contains("secret-token") == false)
|
||||
#expect(url?.absoluteString.contains("secret-password") == false)
|
||||
}
|
||||
|
||||
@Test func `session desktop URL includes the selected source`() throws {
|
||||
@Test func `session desktop URL includes the session key`() throws {
|
||||
let config = try Self.makeConfig(
|
||||
url: #require(URL(string: "ws://192.168.1.10:18789")),
|
||||
token: "secret-token")
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: "node:worker-1")
|
||||
let url = DesktopHubScreen.desktopURL(
|
||||
config: config,
|
||||
source: nil,
|
||||
session: "agent:main:mobile session")
|
||||
|
||||
#expect(url?.absoluteString == "http://192.168.1.10:18789/?view=desktop&source=node%3Aworker-1")
|
||||
#expect(url?.absoluteString == "http://192.168.1.10:18789/?view=desktop&session=agent%3Amain%3Amobile%20session")
|
||||
#expect(url?.absoluteString.contains("secret-token") == false)
|
||||
}
|
||||
|
||||
@Test func `explicit desktop source is retained alongside the session`() throws {
|
||||
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(
|
||||
config: config,
|
||||
source: "node:worker-1",
|
||||
session: "agent:main:mobile")
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com/?view=desktop&source=node%3Aworker-1&session=agent%3Amain%3Amobile")
|
||||
}
|
||||
|
||||
@Test func `empty desktop source is omitted`() throws {
|
||||
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: " ")
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: " ", session: " ")
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com/?view=desktop")
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
||||
let focusProbe = "focus"
|
||||
input.typeText(focusProbe)
|
||||
XCTAssertEqual(input.value as? String, focusProbe)
|
||||
input.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: focusProbe.count))
|
||||
self.clearTextField(input)
|
||||
XCTAssertEqual(input.value as? String, "")
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
|
||||
if keyboard.exists {
|
||||
@@ -918,12 +918,14 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
||||
let host = app.textFields["Host"]
|
||||
XCTAssertTrue(host.waitForExistence(timeout: 5))
|
||||
host.tap()
|
||||
host.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 32) + "localhost")
|
||||
self.clearTextField(host)
|
||||
host.typeText("localhost")
|
||||
|
||||
let port = app.textFields["Port"]
|
||||
XCTAssertTrue(port.waitForExistence(timeout: 5))
|
||||
port.tap()
|
||||
port.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 5) + "18920")
|
||||
self.clearTextField(port)
|
||||
port.typeText("18920")
|
||||
let unencrypted = app.buttons["Unencrypted"]
|
||||
XCTAssertTrue(unencrypted.waitForExistence(timeout: 5))
|
||||
unencrypted.tap()
|
||||
@@ -1523,6 +1525,19 @@ extension OpenClawSnapshotUITests {
|
||||
app.descendants(matching: .any)["chat-message-input"]
|
||||
}
|
||||
|
||||
/// A burst of synthetic key events can drop a keystroke under simulator load, so one
|
||||
/// delete-per-character `typeText` does not reliably empty a field. Re-send against
|
||||
/// whatever is actually left instead of assuming the first burst landed in full.
|
||||
private func clearTextField(_ element: XCUIElement, attempts: Int = 4) {
|
||||
for _ in 0 ..< attempts {
|
||||
let value = element.value as? String ?? ""
|
||||
if value.isEmpty {
|
||||
return
|
||||
}
|
||||
element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count))
|
||||
}
|
||||
}
|
||||
|
||||
private func attachFullScreenScreenshot(named name: String) {
|
||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
attachment.name = name
|
||||
|
||||
@@ -250,6 +250,7 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
.available=${desktopAvailable}
|
||||
.documentMode=${true}
|
||||
.documentSource=${this.desktopOptions.source}
|
||||
.documentSession=${this.desktopOptions.session}
|
||||
.documentControl=${this.desktopOptions.control}
|
||||
.onDocumentClose=${() => {
|
||||
if (globalThis.history.length > 1) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GatewaySessionRow } from "../api/types.ts";
|
||||
import { resolveDesktopDocumentTarget } from "../components/desktop/desktop-source.ts";
|
||||
import { desktopDocumentOptions } from "./desktop-document-mode.ts";
|
||||
|
||||
describe("desktop document mode", () => {
|
||||
it("parses desktop source, session, and control options", () => {
|
||||
expect(
|
||||
desktopDocumentOptions({
|
||||
search: "?view=desktop&source=gateway&session=agent%3Amain%3Awork&control=1",
|
||||
}),
|
||||
).toEqual({
|
||||
source: "gateway",
|
||||
session: "agent:main:work",
|
||||
control: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an explicit source over the session placement", () => {
|
||||
const session = {
|
||||
key: "agent:main:work",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
execNode: "workstation",
|
||||
} satisfies GatewaySessionRow;
|
||||
|
||||
expect(
|
||||
resolveDesktopDocumentTarget(
|
||||
{ source: "gateway", session: session.key, control: false },
|
||||
session,
|
||||
),
|
||||
).toBe("gateway");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"cloud placement",
|
||||
{
|
||||
key: "agent:main:cloud",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
placement: { state: "active", environmentId: "worker:cloud-1" },
|
||||
} as GatewaySessionRow,
|
||||
"worker:cloud-1",
|
||||
],
|
||||
[
|
||||
"execution node",
|
||||
{
|
||||
key: "agent:main:node",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
execNode: "workstation",
|
||||
} satisfies GatewaySessionRow,
|
||||
"node:workstation",
|
||||
],
|
||||
[
|
||||
"gateway fallback",
|
||||
{
|
||||
key: "agent:main:gateway",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
} satisfies GatewaySessionRow,
|
||||
"gateway",
|
||||
],
|
||||
])("resolves a session's %s through the chat placement owner", (_label, session, expected) => {
|
||||
expect(
|
||||
resolveDesktopDocumentTarget({ source: null, session: session.key, control: false }, session),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns no target for an unknown session", () => {
|
||||
expect(
|
||||
resolveDesktopDocumentTarget(
|
||||
{ source: null, session: "agent:main:missing", control: false },
|
||||
undefined,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,9 @@ const DESKTOP_DOCUMENT_PATH = "/desktop";
|
||||
|
||||
type DesktopDocumentLocation = Pick<Location, "pathname" | "search">;
|
||||
|
||||
type DesktopDocumentOptions = {
|
||||
export type DesktopDocumentOptions = {
|
||||
source: string | null;
|
||||
session: string | null;
|
||||
control: boolean;
|
||||
};
|
||||
|
||||
@@ -33,6 +34,7 @@ export function desktopDocumentOptions(
|
||||
const search = new URLSearchParams(location?.search ?? "");
|
||||
return {
|
||||
source: search.get("source"),
|
||||
session: search.get("session"),
|
||||
control: search.get("control") === "1",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Mobile browsers only report composed text through an input's value, so the desktop document
|
||||
* keeps a padded sentinel in the field and derives key events from how that value changed.
|
||||
*/
|
||||
const MOBILE_KEYBOARD_SENTINEL = "________________";
|
||||
|
||||
type MobileKeyboardOptions = {
|
||||
/** Live RFB handle, or null while disconnected; absent senders mean a view-only transport. */
|
||||
connection: () => {
|
||||
sendBackspace?: () => void;
|
||||
sendKeyboardEvent?: (event: KeyboardEvent) => void;
|
||||
sendText?: (text: string) => void;
|
||||
} | null;
|
||||
controlling: () => boolean;
|
||||
input: () => HTMLTextAreaElement | null | undefined;
|
||||
};
|
||||
|
||||
/** Bridges the desktop document's hidden textarea to the remote desktop's keyboard. */
|
||||
export class DesktopMobileKeyboard {
|
||||
/** The document view renders this so the field always holds deletable padding. */
|
||||
value = MOBILE_KEYBOARD_SENTINEL;
|
||||
|
||||
constructor(private readonly options: MobileKeyboardOptions) {}
|
||||
|
||||
focus(): void {
|
||||
const input = this.options.input();
|
||||
input?.focus({ preventScroll: true });
|
||||
input?.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
|
||||
reset(input?: HTMLTextAreaElement): void {
|
||||
this.value = MOBILE_KEYBOARD_SENTINEL;
|
||||
const target = input ?? this.options.input();
|
||||
if (target) {
|
||||
target.value = MOBILE_KEYBOARD_SENTINEL;
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyboardEvent(event: KeyboardEvent): void {
|
||||
const connection = this.options.connection();
|
||||
if (!this.options.controlling() || !connection?.sendKeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
connection.sendKeyboardEvent(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
handleInput(event: InputEvent): void {
|
||||
const input = event.currentTarget as HTMLTextAreaElement;
|
||||
if (!this.options.controlling()) {
|
||||
this.reset(input);
|
||||
return;
|
||||
}
|
||||
const nextValue = input.value;
|
||||
const connection = this.options.connection();
|
||||
let prefixLength = 0;
|
||||
const comparableLength = Math.min(this.value.length, nextValue.length);
|
||||
while (
|
||||
prefixLength < comparableLength &&
|
||||
this.value.charAt(prefixLength) === nextValue.charAt(prefixLength)
|
||||
) {
|
||||
prefixLength += 1;
|
||||
}
|
||||
for (let index = this.value.length - prefixLength; index > 0; index -= 1) {
|
||||
connection?.sendBackspace?.();
|
||||
}
|
||||
connection?.sendText?.(nextValue.slice(prefixLength));
|
||||
// Refill once the field drifts outside the range that keeps further deletes reportable.
|
||||
if (nextValue.length < 1 || nextValue.length > MOBILE_KEYBOARD_SENTINEL.length * 2) {
|
||||
this.reset(input);
|
||||
return;
|
||||
}
|
||||
this.value = nextValue;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { html, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatUiError } from "../../lib/format-error.ts";
|
||||
import { OpenClawLitElement } from "../../lit/openclaw-element.ts";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { DesktopClient, type DesktopConnectionHandle } from "./desktop-client.ts";
|
||||
import { desktopDocumentStyles } from "./desktop-document-styles.ts";
|
||||
import { renderDesktopDocumentView } from "./desktop-document-view.ts";
|
||||
import { DesktopMobileKeyboard } from "./desktop-mobile-keyboard.ts";
|
||||
import type {
|
||||
DesktopAppId,
|
||||
DesktopCredentials,
|
||||
@@ -38,7 +40,7 @@ import {
|
||||
renderDesktopPanelHeader,
|
||||
renderDesktopPicker,
|
||||
} from "./desktop-panel-view.ts";
|
||||
import { desktopSourceForEnvironment } from "./desktop-source.ts";
|
||||
import { desktopSourceForEnvironment, resolveDesktopDocumentTarget } from "./desktop-source.ts";
|
||||
|
||||
const panelLayout = createDockPanelLayout({
|
||||
storageKey: "openclaw.desktopPanel",
|
||||
@@ -49,7 +51,6 @@ const panelLayout = createDockPanelLayout({
|
||||
defaultHeight: 420,
|
||||
defaultWidth: 560,
|
||||
});
|
||||
const MOBILE_KEYBOARD_SENTINEL = "________________";
|
||||
/** `<openclaw-desktop-panel>` — dockable RFB access to Gateway desktop sources. */
|
||||
class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
@property({ attribute: false }) client: GatewayBrowserClient | null = null;
|
||||
@@ -57,6 +58,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
@property({ type: Boolean }) suppressed = false;
|
||||
@property({ type: Boolean }) documentMode = false;
|
||||
@property({ attribute: false }) documentSource: string | null = null;
|
||||
@property({ attribute: false }) documentSession: string | null = null;
|
||||
@property({ type: Boolean }) documentControl = false;
|
||||
@property({ attribute: false }) onDocumentClose: (() => void) | null = null;
|
||||
|
||||
@@ -85,7 +87,11 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
private launchOperationId = 0;
|
||||
private controlTakeoverRecoveryUsed = false;
|
||||
private documentSourceResolved = false;
|
||||
private keyboardInputValue = MOBILE_KEYBOARD_SENTINEL;
|
||||
private readonly mobileKeyboard = new DesktopMobileKeyboard({
|
||||
connection: () => this.connection,
|
||||
controlling: () => this.controlling,
|
||||
input: () => this.shadowRoot?.querySelector<HTMLTextAreaElement>(".desktop-keyboard-input"),
|
||||
});
|
||||
private readonly dockLayout = new DockLayoutController(this, {
|
||||
layout: panelLayout,
|
||||
reservationPrefix: "desktop",
|
||||
@@ -132,13 +138,14 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
void this.refreshEnvironments();
|
||||
}
|
||||
}
|
||||
if (changed.has("documentSource")) {
|
||||
if (changed.has("documentSource") || changed.has("documentSession")) {
|
||||
this.documentSourceResolved = false;
|
||||
}
|
||||
const gatewayAvailabilityChanged = changed.has("client") || changed.has("available");
|
||||
const documentPresentationChanged =
|
||||
changed.has("documentMode") ||
|
||||
changed.has("documentSource") ||
|
||||
changed.has("documentSession") ||
|
||||
changed.has("documentControl");
|
||||
if (this.documentMode && (gatewayAvailabilityChanged || documentPresentationChanged)) {
|
||||
if (!this.available) {
|
||||
@@ -211,7 +218,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
const connection = this.connection;
|
||||
this.connection = null;
|
||||
connection?.disconnect();
|
||||
this.resetDocumentKeyboardInput();
|
||||
this.mobileKeyboard.reset();
|
||||
}
|
||||
|
||||
private clearLaunchState(): void {
|
||||
@@ -239,7 +246,9 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
} catch (error) {
|
||||
if (operationId === this.operationId) {
|
||||
this.errorText = t("desktop.errors.listFailed", { error: formatUiError(error) });
|
||||
if (this.documentMode && this.documentSource !== null) {
|
||||
if (this.documentMode && (this.documentSource !== null || this.documentSession !== null)) {
|
||||
// A session key only names a machine once the inventory loads, so it stays out of
|
||||
// `environmentId`; document-mode retry refreshes the inventory rather than reconnecting.
|
||||
this.environmentId = this.documentSource;
|
||||
this.state = "inventory-error";
|
||||
}
|
||||
@@ -260,8 +269,36 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
return;
|
||||
}
|
||||
this.documentSourceResolved = true;
|
||||
const requestedSource = this.documentSource;
|
||||
let session: GatewaySessionRow | undefined;
|
||||
if (this.documentSource === null && this.documentSession !== null) {
|
||||
// `sessions.describe` is the exact-key lookup; a `sessions.list` search would have to
|
||||
// page past same-agent keys that share this one's prefix before it could rule it out.
|
||||
try {
|
||||
const described = await this.client?.request<{ session?: GatewaySessionRow | null }>(
|
||||
"sessions.describe",
|
||||
{ key: this.documentSession },
|
||||
);
|
||||
session = described?.session ?? undefined;
|
||||
} catch {
|
||||
session = undefined;
|
||||
}
|
||||
if (operationId !== this.operationId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const requestedSource = resolveDesktopDocumentTarget(
|
||||
{
|
||||
source: this.documentSource,
|
||||
session: this.documentSession,
|
||||
control: this.documentControl,
|
||||
},
|
||||
session,
|
||||
);
|
||||
if (requestedSource === null) {
|
||||
if (this.documentSession !== null) {
|
||||
this.state = "picker";
|
||||
this.noticeText = t("desktop.sourceUnavailable");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.environments.some((environment) => environment.id === requestedSource)) {
|
||||
@@ -543,57 +580,6 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private handleDocumentKeyboardEvent(event: KeyboardEvent): void {
|
||||
if (!this.controlling || !this.connection?.sendKeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
this.connection.sendKeyboardEvent(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private handleDocumentKeyboardInput(event: InputEvent): void {
|
||||
const input = event.currentTarget as HTMLTextAreaElement;
|
||||
if (!this.controlling) {
|
||||
this.resetDocumentKeyboardInput(input);
|
||||
return;
|
||||
}
|
||||
const previousValue = this.keyboardInputValue;
|
||||
const nextValue = input.value;
|
||||
let prefixLength = 0;
|
||||
const comparableLength = Math.min(previousValue.length, nextValue.length);
|
||||
while (
|
||||
prefixLength < comparableLength &&
|
||||
previousValue.charAt(prefixLength) === nextValue.charAt(prefixLength)
|
||||
) {
|
||||
prefixLength += 1;
|
||||
}
|
||||
const removedCount = previousValue.length - prefixLength;
|
||||
for (let index = 0; index < removedCount; index += 1) {
|
||||
this.connection?.sendBackspace?.();
|
||||
}
|
||||
this.connection?.sendText?.(nextValue.slice(prefixLength));
|
||||
if (nextValue.length < 1 || nextValue.length > MOBILE_KEYBOARD_SENTINEL.length * 2) {
|
||||
this.resetDocumentKeyboardInput(input);
|
||||
return;
|
||||
}
|
||||
this.keyboardInputValue = nextValue;
|
||||
}
|
||||
|
||||
private resetDocumentKeyboardInput(input?: HTMLTextAreaElement): void {
|
||||
this.keyboardInputValue = MOBILE_KEYBOARD_SENTINEL;
|
||||
const target =
|
||||
input ?? this.shadowRoot?.querySelector<HTMLTextAreaElement>(".desktop-keyboard-input");
|
||||
if (target) {
|
||||
target.value = MOBILE_KEYBOARD_SENTINEL;
|
||||
}
|
||||
}
|
||||
|
||||
private focusDocumentKeyboard(): void {
|
||||
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>(".desktop-keyboard-input");
|
||||
input?.focus({ preventScroll: true });
|
||||
input?.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
|
||||
private toggleDocumentScale(): void {
|
||||
this.scaleViewport = !this.scaleViewport;
|
||||
this.connection?.setScaleViewport?.(this.scaleViewport);
|
||||
@@ -622,15 +608,15 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
inventoryError: this.state === "inventory-error",
|
||||
reason: this.disconnectedReason,
|
||||
onRetry: () => {
|
||||
if (this.state === "inventory-error" && this.documentMode) {
|
||||
this.retryDocumentInventory();
|
||||
return;
|
||||
}
|
||||
if (!this.environmentId) {
|
||||
return;
|
||||
}
|
||||
if (this.state === "inventory-error") {
|
||||
if (this.documentMode) {
|
||||
this.retryDocumentInventory();
|
||||
} else {
|
||||
void this.connectRequestedEnvironment(this.environmentId);
|
||||
}
|
||||
void this.connectRequestedEnvironment(this.environmentId);
|
||||
return;
|
||||
}
|
||||
void this.connectEnvironment(this.environmentId, this.controlling);
|
||||
@@ -656,7 +642,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
state: this.state,
|
||||
controlling: this.controlling,
|
||||
scaleViewport: this.scaleViewport,
|
||||
keyboardInputValue: this.keyboardInputValue,
|
||||
keyboardInputValue: this.mobileKeyboard.value,
|
||||
notice,
|
||||
picker,
|
||||
credentials,
|
||||
@@ -666,9 +652,9 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
void this.connectEnvironment(this.environmentId, !this.controlling);
|
||||
}
|
||||
},
|
||||
onKeyboardFocus: () => this.focusDocumentKeyboard(),
|
||||
onKeyboardEvent: (event) => this.handleDocumentKeyboardEvent(event),
|
||||
onKeyboardInput: (event) => this.handleDocumentKeyboardInput(event),
|
||||
onKeyboardFocus: () => this.mobileKeyboard.focus(),
|
||||
onKeyboardEvent: (event) => this.mobileKeyboard.handleKeyboardEvent(event),
|
||||
onKeyboardInput: (event) => this.mobileKeyboard.handleInput(event),
|
||||
onScaleToggle: () => this.toggleDocumentScale(),
|
||||
onClose: () => this.onDocumentClose?.(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DesktopSource, EnvironmentSummary } from "@openclaw/gateway-protocol";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { DesktopDocumentOptions } from "../../app/desktop-document-mode.ts";
|
||||
import { resolveChatPaneDesktopTarget } from "../../pages/chat/chat-pane-placement.ts";
|
||||
|
||||
export function desktopSourceForEnvironment(
|
||||
environment: Pick<EnvironmentSummary, "id">,
|
||||
@@ -11,3 +14,14 @@ export function desktopSourceForEnvironment(
|
||||
}
|
||||
return { kind: "environment", environmentId: environment.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lives beside the lazily loaded panel rather than in the route module: the chat placement
|
||||
* owner pulls the chat page's dependency tree, which must stay out of the startup chunk.
|
||||
*/
|
||||
export function resolveDesktopDocumentTarget(
|
||||
options: DesktopDocumentOptions,
|
||||
session: GatewaySessionRow | undefined,
|
||||
): string | null {
|
||||
return options.source ?? (options.session ? resolveChatPaneDesktopTarget(session) : null);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ async function startDesktopDocument(
|
||||
expiresAtMs: 60_000,
|
||||
control: false,
|
||||
},
|
||||
describedSession?: unknown,
|
||||
) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const gateway = await installMockGateway(page, {
|
||||
@@ -89,6 +90,9 @@ async function startDesktopDocument(
|
||||
featureMethods: ["desktop.observe", "environments.list", "openclaw.setup.detect"],
|
||||
methodResponses: {
|
||||
"desktop.observe": desktopObserve,
|
||||
...(describedSession === undefined
|
||||
? {}
|
||||
: { "sessions.describe": { session: describedSession } }),
|
||||
"openclaw.setup.detect": {
|
||||
candidates: [],
|
||||
manualProviders: [],
|
||||
@@ -110,8 +114,9 @@ async function openDesktopDocument(
|
||||
route: string,
|
||||
environments: unknown[],
|
||||
desktopObserve?: unknown,
|
||||
describedSession?: unknown,
|
||||
) {
|
||||
const document = await startDesktopDocument(page, route, desktopObserve);
|
||||
const document = await startDesktopDocument(page, route, desktopObserve, describedSession);
|
||||
await document.gateway.resolveDeferred("environments.list", { environments });
|
||||
return document;
|
||||
}
|
||||
@@ -164,6 +169,99 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a session to its observable machine and auto-connects", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const sessionKey = "agent:main:mobile-session";
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
page,
|
||||
`?view=desktop&session=${encodeURIComponent(sessionKey)}`,
|
||||
[
|
||||
gatewayEnvironment,
|
||||
{
|
||||
id: "node:workstation",
|
||||
type: "node",
|
||||
status: "available",
|
||||
desktop: true,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
{
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
execNode: "workstation",
|
||||
},
|
||||
);
|
||||
|
||||
const request = await gateway.waitForRequest("desktop.observe");
|
||||
expect(request.params).toEqual({
|
||||
source: { kind: "node", nodeId: "workstation" },
|
||||
control: false,
|
||||
});
|
||||
await panel.locator("[data-test-remote-desktop='true']").waitFor();
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDirectory, "session-connected-390x844.png"),
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lets an explicit source win over the session machine", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const sessionKey = "agent:main:mobile-session";
|
||||
const { gateway } = await openDesktopDocument(
|
||||
page,
|
||||
`?view=desktop&source=gateway&session=${encodeURIComponent(sessionKey)}`,
|
||||
[
|
||||
gatewayEnvironment,
|
||||
{
|
||||
id: "node:workstation",
|
||||
type: "node",
|
||||
status: "available",
|
||||
desktop: true,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
{
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
execNode: "workstation",
|
||||
},
|
||||
);
|
||||
|
||||
const request = await gateway.waitForRequest("desktop.observe");
|
||||
expect(request.params).toEqual({ source: { kind: "host" }, control: false });
|
||||
expect(await gateway.getRequests("sessions.describe")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the picker with a notice for an unknown session", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
page,
|
||||
"?view=desktop&session=agent%3Amain%3Amissing",
|
||||
[gatewayEnvironment],
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
|
||||
await panel
|
||||
.getByText("The requested desktop source is unavailable. Choose another source.", {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
await panel.getByText("Desktop sources", { exact: true }).waitFor();
|
||||
expect(await gateway.getRequests("desktop.observe")).toHaveLength(0);
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDirectory, "unknown-session-picker-390x844.png"),
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("renders inventory failure recovery and retries the preselected source", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await startDesktopDocument(page, "?view=desktop&source=gateway");
|
||||
@@ -187,6 +285,45 @@ suite.define(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers a session-preselected desktop after an inventory failure", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const sessionKey = "agent:main:mobile-session";
|
||||
const nodeEnvironment = {
|
||||
id: "node:workstation",
|
||||
type: "node",
|
||||
status: "available",
|
||||
desktop: true,
|
||||
};
|
||||
const { gateway, panel } = await startDesktopDocument(
|
||||
page,
|
||||
`?view=desktop&session=${encodeURIComponent(sessionKey)}`,
|
||||
undefined,
|
||||
{ key: sessionKey, kind: "direct", updatedAt: 1, execNode: "workstation" },
|
||||
);
|
||||
await gateway.rejectDeferred("environments.list", {
|
||||
code: "UNAVAILABLE",
|
||||
message: "desktop inventory is temporarily unavailable",
|
||||
});
|
||||
|
||||
// A session key only names a machine once the inventory loads, so recovery here has no
|
||||
// preselected environment to reconnect to and must retry the inventory instead.
|
||||
const retry = panel.getByRole("button", { name: "Retry", exact: true });
|
||||
await retry.waitFor();
|
||||
expect(await gateway.getRequests("desktop.observe")).toHaveLength(0);
|
||||
|
||||
await gateway.setMethodResponse("environments.list", {
|
||||
environments: [gatewayEnvironment, nodeEnvironment],
|
||||
});
|
||||
await retry.click();
|
||||
const observeRequest = await gateway.waitForRequest("desktop.observe");
|
||||
expect(observeRequest.params).toEqual({
|
||||
source: { kind: "node", nodeId: "workstation" },
|
||||
control: false,
|
||||
});
|
||||
await panel.locator("[data-test-remote-desktop='true']").waitFor();
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-connects view-only and provides four working touch actions", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
|
||||
Reference in New Issue
Block a user