mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): refresh device labels after rename (#126432)
* fix(ui): refresh device labels after rename * fix(ci): classify device label refresh event for mobile * chore(protocol): regenerate Android gateway events
This commit is contained in:
committed by
GitHub
parent
513ec6218c
commit
a3ca8466ee
@@ -669,6 +669,7 @@ enum class GatewayEvent(
|
||||
NodeInvokeCancel("node.invoke.cancel"),
|
||||
NodeInvokeInput("node.invoke.input"),
|
||||
NodeInvokeRequest("node.invoke.request"),
|
||||
DevicePairChanged("device.pair.changed"),
|
||||
DevicePairRequested("device.pair.requested"),
|
||||
DevicePairResolved("device.pair.resolved"),
|
||||
DevicePairSetupCompleted("device.pair.setup.completed"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"ios": {
|
||||
"controlUi.sessionPullRequests.changed": "Sidebar PR indicators are a Control UI surface; native apps do not render session PR chips.",
|
||||
"cron": "Cron run activity is not surfaced in the iOS app.",
|
||||
"device.pair.changed": "Device label projection refresh is a Control UI surface; iOS does not render the Gateway Devices page.",
|
||||
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on iOS.",
|
||||
"device.pair.resolved": "Device pairing flows poll via device.pair.* methods on iOS.",
|
||||
"device.pair.setup.completed": "Generated setup lifecycle is a Control UI surface; iOS direct Watch setup completes through Watch connectivity.",
|
||||
@@ -36,6 +37,7 @@
|
||||
"android": {
|
||||
"controlUi.sessionPullRequests.changed": "Sidebar PR indicators are a Control UI surface; native apps do not render session PR chips.",
|
||||
"cron": "Cron run activity is not surfaced in the Android app.",
|
||||
"device.pair.changed": "Device label projection refresh is a Control UI surface; Android does not render the Gateway Devices page.",
|
||||
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on Android.",
|
||||
"device.pair.resolved": "Device pairing flows poll via device.pair.* methods on Android.",
|
||||
"device.pair.setup.completed": "Generated setup lifecycle is a Control UI surface; Android consumes setup codes but does not issue or track them.",
|
||||
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
import { READ_SCOPE } from "./operator-scopes.js";
|
||||
|
||||
/** Event name emitted when the paired-device projection changes. */
|
||||
export const GATEWAY_EVENT_DEVICE_PAIR_CHANGED = "device.pair.changed" as const;
|
||||
|
||||
/** Event name emitted when a node's private runner declaration changes. */
|
||||
export const GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED = "node.runnerInventory.changed" as const;
|
||||
|
||||
|
||||
@@ -530,42 +530,35 @@ describe("gateway broadcaster", () => {
|
||||
});
|
||||
|
||||
it("filters approval and pairing events by scope", () => {
|
||||
const approvalsSocket: TestSocket = {
|
||||
bufferedAmount: 0,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
const pairingSocket: TestSocket = {
|
||||
bufferedAmount: 0,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
const readSocket: TestSocket = {
|
||||
bufferedAmount: 0,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
const approvalsSocket = makeRecordingSocket();
|
||||
const pairingSocket = makeRecordingSocket();
|
||||
const readSocket = makeRecordingSocket();
|
||||
const adminSocket = makeRecordingSocket();
|
||||
|
||||
const clients = new Set<GatewayWsClient>([
|
||||
makeOperatorWsClient("c-approvals", approvalsSocket, ["operator.approvals"]),
|
||||
makeOperatorWsClient("c-pairing", pairingSocket, ["operator.pairing"]),
|
||||
makeOperatorWsClient("c-read", readSocket, ["operator.read"]),
|
||||
makeOperatorWsClient("c-admin", adminSocket, ["operator.admin"]),
|
||||
]);
|
||||
|
||||
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
|
||||
|
||||
broadcast("exec.approval.requested", { id: "1" });
|
||||
broadcast("device.pair.requested", { requestId: "r1" });
|
||||
broadcast("device.pair.changed", {});
|
||||
|
||||
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(pairingSocket.send).toHaveBeenCalledTimes(2);
|
||||
expect(readSocket.send).toHaveBeenCalledTimes(0);
|
||||
expect(adminSocket.send).toHaveBeenCalledTimes(3);
|
||||
|
||||
broadcastToConnIds("tick", { ts: 1 }, new Set(["c-read"]));
|
||||
broadcastToConnIds("talk.event", { type: "session.ready" }, new Set(["c-read"]));
|
||||
expect(readSocket.send).toHaveBeenCalledTimes(2);
|
||||
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(pairingSocket.send).toHaveBeenCalledTimes(2);
|
||||
expect(adminSocket.send).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("requires operator.read for chat-class broadcast events", () => {
|
||||
|
||||
@@ -9,7 +9,10 @@ import { logRejectedLargePayload } from "../logging/diagnostic-payload.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { queuePluginSessionsChanged } from "../plugins/gateway-events.js";
|
||||
import { isBrowserCopilotClient } from "../utils/message-channel.js";
|
||||
import { GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED } from "./events.js";
|
||||
import {
|
||||
GATEWAY_EVENT_DEVICE_PAIR_CHANGED,
|
||||
GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED,
|
||||
} from "./events.js";
|
||||
import {
|
||||
ADMIN_SCOPE,
|
||||
APPROVALS_SCOPE,
|
||||
@@ -69,6 +72,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
|
||||
"skills.changed": [READ_SCOPE],
|
||||
"voicewake.changed": [READ_SCOPE],
|
||||
"voicewake.routing.changed": [READ_SCOPE],
|
||||
[GATEWAY_EVENT_DEVICE_PAIR_CHANGED]: [PAIRING_SCOPE],
|
||||
"device.pair.requested": [PAIRING_SCOPE],
|
||||
"device.pair.resolved": [PAIRING_SCOPE],
|
||||
"device.pair.setup.completed": [PAIRING_SCOPE],
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("GATEWAY_EVENTS", () => {
|
||||
it("advertises node topology updates", () => {
|
||||
expect(GATEWAY_EVENTS).toContain("node.presence");
|
||||
expect(GATEWAY_EVENTS).toContain("device.pair.setup.completed");
|
||||
expect(GATEWAY_EVENTS).toContain("device.pair.changed");
|
||||
expect(GATEWAY_EVENTS).toContain("node.runnerInventory.changed");
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Lists advertised core, auxiliary, channel plugin methods, and websocket events.
|
||||
import { listLoadedChannelPlugins } from "../channels/plugins/registry-loaded.js";
|
||||
import {
|
||||
GATEWAY_EVENT_DEVICE_PAIR_CHANGED,
|
||||
GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED,
|
||||
GATEWAY_EVENT_UPDATE_AVAILABLE,
|
||||
} from "./events.js";
|
||||
@@ -71,6 +72,7 @@ export const GATEWAY_EVENTS = [
|
||||
"node.invoke.cancel",
|
||||
"node.invoke.input",
|
||||
"node.invoke.request",
|
||||
GATEWAY_EVENT_DEVICE_PAIR_CHANGED,
|
||||
"device.pair.requested",
|
||||
"device.pair.resolved",
|
||||
"device.pair.setup.completed",
|
||||
|
||||
@@ -1657,6 +1657,11 @@ describe("deviceHandlers", () => {
|
||||
expect(opts.context.logGateway.info).toHaveBeenCalledWith(
|
||||
"device pairing renamed device=device-1 label=Kitchen Mac",
|
||||
);
|
||||
expect(opts.context.broadcast).toHaveBeenCalledWith(
|
||||
"device.pair.changed",
|
||||
{},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects renaming another device from a non-admin device session", async () => {
|
||||
@@ -1690,6 +1695,7 @@ describe("deviceHandlers", () => {
|
||||
expect(updatePairedDeviceMetadataMock).toHaveBeenCalledWith("missing-device", {
|
||||
operatorLabel: "Ghost",
|
||||
});
|
||||
expect(opts.context.broadcast).not.toHaveBeenCalled();
|
||||
expectRespondedErrorMessage(opts, "unknown deviceId");
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "../../infra/device-pairing.js";
|
||||
import type { DiagnosticSecurityEventInput } from "../../infra/diagnostic-events.js";
|
||||
import { reconcileRevokedDeviceWorker } from "../device-worker-revocation.js";
|
||||
import { GATEWAY_EVENT_DEVICE_PAIR_CHANGED } from "../events.js";
|
||||
import { clearRemovedNodeRuntimeState } from "../node-runtime-state.js";
|
||||
import { invalidateNodeWakeState } from "../node-wake-state.js";
|
||||
import {
|
||||
@@ -551,6 +552,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
targetDeviceId: deviceId,
|
||||
controlId: "device.pair.rename",
|
||||
});
|
||||
context.broadcast(GATEWAY_EVENT_DEVICE_PAIR_CHANGED, {}, { dropIfSlow: true });
|
||||
respond(true, { deviceId, label: trimmed }, undefined);
|
||||
},
|
||||
"device.token.rotate": async ({ params, respond, context, client }) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { PresenceEntry } from "../../api/types.ts";
|
||||
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
createInitialDevicesState,
|
||||
loadDevices,
|
||||
loadNodes,
|
||||
@@ -266,12 +265,15 @@ describe("DevicesPage gateway lifecycle", () => {
|
||||
page.remove();
|
||||
});
|
||||
|
||||
it("coalesces a device refresh requested while an older list is loading", async () => {
|
||||
it("refetches a changed device label after an older list response", async () => {
|
||||
const stale = deferred<{
|
||||
paired: [];
|
||||
pending: Array<{ requestId: string; deviceId: string }>;
|
||||
paired: Array<{ deviceId: string; displayName: string }>;
|
||||
pending: [];
|
||||
}>();
|
||||
const refreshed = deferred<{
|
||||
paired: Array<{ deviceId: string; displayName: string; operatorLabel: string }>;
|
||||
pending: [];
|
||||
}>();
|
||||
const refreshed = deferred<{ paired: []; pending: [] }>();
|
||||
let listCalls = 0;
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "device.pair.list") {
|
||||
@@ -281,23 +283,55 @@ describe("DevicesPage gateway lifecycle", () => {
|
||||
return Promise.resolve({});
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const state = createInitialDevicesState({ client, connected: true });
|
||||
const snapshot = {
|
||||
...gatewaySnapshot(client, true),
|
||||
hello: {
|
||||
type: "hello-ok",
|
||||
protocol: 1,
|
||||
auth: { role: "operator", scopes: ["operator.pairing"] },
|
||||
features: { methods: ["device.pair.list"] },
|
||||
},
|
||||
} as ApplicationGatewaySnapshot;
|
||||
let onEvent: ((event: { event: string; payload?: unknown }) => void) | undefined;
|
||||
const currentGateway = gateway(client, snapshot);
|
||||
currentGateway.subscribeEvents = vi.fn((listener) => {
|
||||
onEvent = listener as typeof onEvent;
|
||||
return () => undefined;
|
||||
});
|
||||
const page = document.createElement("openclaw-devices-page") as TestDevicesPage;
|
||||
page.context = {
|
||||
gateway: currentGateway,
|
||||
runtimeConfig: {
|
||||
state: { configSnapshot: {}, configLoading: false },
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
page.pageState = createInitialDevicesState({ client, connected: true });
|
||||
document.body.append(page);
|
||||
await vi.waitFor(() => expect(onEvent).toBeDefined());
|
||||
|
||||
const initialLoad = loadDevices(state);
|
||||
const initialLoad = loadDevices(page.pageState);
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("device.pair.list", {}));
|
||||
const approval = approveDevicePairing(state, "request-1");
|
||||
await vi.waitFor(() =>
|
||||
expect(request).toHaveBeenCalledWith("device.pair.approve", { requestId: "request-1" }),
|
||||
);
|
||||
onEvent?.({ event: "device.pair.changed", payload: {} });
|
||||
|
||||
stale.resolve({ paired: [], pending: [{ requestId: "request-1", deviceId: "device-1" }] });
|
||||
stale.resolve({
|
||||
paired: [{ deviceId: "device-1", displayName: "Kitchen Mac" }],
|
||||
pending: [],
|
||||
});
|
||||
await vi.waitFor(() => expect(listCalls).toBe(2));
|
||||
expect(state.devicesLoading).toBe(true);
|
||||
expect(page.pageState.devicesLoading).toBe(true);
|
||||
|
||||
refreshed.resolve({ paired: [], pending: [] });
|
||||
await Promise.all([initialLoad, approval]);
|
||||
expect(state.devicesList).toEqual({ paired: [], pending: [] });
|
||||
expect(state.devicesLoading).toBe(false);
|
||||
refreshed.resolve({
|
||||
paired: [{ deviceId: "device-1", displayName: "Kitchen Mac", operatorLabel: "Studio Mac" }],
|
||||
pending: [],
|
||||
});
|
||||
await initialLoad;
|
||||
expect(page.pageState.devicesList).toEqual({
|
||||
paired: [{ deviceId: "device-1", displayName: "Kitchen Mac", operatorLabel: "Studio Mac" }],
|
||||
pending: [],
|
||||
});
|
||||
expect(page.pageState.devicesLoading).toBe(false);
|
||||
page.remove();
|
||||
});
|
||||
|
||||
it("coalesces a node refresh requested while an older list is loading", async () => {
|
||||
@@ -357,7 +391,7 @@ describe("DevicesPage gateway lifecycle", () => {
|
||||
expect(request.mock.calls.map(([method]) => method)).not.toContain("exec.approvals.get");
|
||||
});
|
||||
|
||||
it("keeps presence-driven device reloads gated on pairing access", async () => {
|
||||
it("keeps event-driven device reloads gated on pairing access", async () => {
|
||||
const request = vi.fn(async (method: string) => (method === "node.list" ? { nodes: [] } : {}));
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const snapshot = {
|
||||
@@ -398,6 +432,10 @@ describe("DevicesPage gateway lifecycle", () => {
|
||||
).toBeGreaterThan(nodeListCallsBefore.length),
|
||||
);
|
||||
expect(request.mock.calls.map(([method]) => method)).not.toContain("device.pair.list");
|
||||
|
||||
onEvent?.({ event: "device.pair.changed", payload: {} });
|
||||
await Promise.resolve();
|
||||
expect(request.mock.calls.map(([method]) => method)).not.toContain("device.pair.list");
|
||||
page.remove();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { consume } from "@lit/context";
|
||||
import { initialState, Task } from "@lit/task";
|
||||
import { html, type PropertyValues } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { GATEWAY_EVENT_DEVICE_PAIR_CHANGED } from "../../../../src/gateway/events.js";
|
||||
import type { PresenceEntry } from "../../api/types.ts";
|
||||
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
|
||||
import {
|
||||
@@ -166,7 +167,11 @@ class DevicesPage extends OpenClawLightDomElement {
|
||||
void this.runPageTask((pageState) => loadNodes(pageState, { quiet: true }));
|
||||
}
|
||||
}
|
||||
if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") {
|
||||
if (
|
||||
event.event === GATEWAY_EVENT_DEVICE_PAIR_CHANGED ||
|
||||
event.event === "device.pair.requested" ||
|
||||
event.event === "device.pair.resolved"
|
||||
) {
|
||||
if (this.canManagePairing) {
|
||||
void this.runPageTask((pageState) => loadDevices(pageState, { quiet: true }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user