From 75e165e1cb8fac517481b38e364d5767780b95ba Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 21 Sep 2026 10:35:24 +0800 Subject: [PATCH 1/3] feat(web-ui): draw every device as the system it runs The device overview knew which system each device reported and drew one generic monitor for all of them: in the footer trigger, in the attached group, in the carousel and in the connect list. Each of those places now draws the system that device reported - the Apple mark for macOS, the Windows flag, Tux, the HarmonyOS mark, and the server silhouette for a headless host - and keeps the monitor only for a device whose system cannot be placed. - The marks and their system resolution move to src/shared, so the desktop shell and mobile web draw one device the same way. Each surface keeps its own sizes, colours and placement. - This machine's own system and kind now come from its account directory row. The device info command answers with identity only, so the local source was always empty and the local device fell back to the neutral mark. - A mark states its own optical correction, measured from the mass of its own drawing: the Apple's leaf is thin above a heavy body, so its mass sits about 9% below the centre of its box and the mark read as low beside a line of text. - The footer trigger's mark is placed on that line's optical centre instead of its line box, which is split by the font's ascent and descent rather than by the glyphs drawn in it. A phone stays a phone and a chat app keeps its brand, because neither is a system this client can draw. Co-authored-by: bitfun-ai <318544290+bitfun-ai@users.noreply.github.com> --- src/shared/device-system/deviceSystemMarks.ts | 82 +++++++++++ .../src/app/components/NavPanel/NavPanel.scss | 14 +- .../components/DeviceStatusControl.test.tsx | 105 ++++++++++++++ .../components/DeviceStatusControl.tsx | 62 ++++++-- .../NavPanel/components/DeviceSystemGlyph.tsx | 36 ++++- .../NavPanel/components/deviceArtworkKind.ts | 22 +-- .../NavPanel/components/deviceSystemMarks.ts | 45 +----- .../useDeviceInterconnectionOverview.test.tsx | 135 ++++++++++++++++++ .../useDeviceInterconnectionOverview.ts | 14 +- .../RemoteConnectDialog.contract.test.ts | 6 +- 10 files changed, 442 insertions(+), 79 deletions(-) create mode 100644 src/shared/device-system/deviceSystemMarks.ts create mode 100644 src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.test.tsx diff --git a/src/shared/device-system/deviceSystemMarks.ts b/src/shared/device-system/deviceSystemMarks.ts new file mode 100644 index 0000000000..134d0dc0e2 --- /dev/null +++ b/src/shared/device-system/deviceSystemMarks.ts @@ -0,0 +1,82 @@ +/** + * Monochrome contours of the systems a host can report, for a row-sized mark. + * Windows, macOS and HarmonyOS are the CC0 simple-icons vectors, `server` is the + * server silhouette the device artwork draws, and Linux is the Font Awesome Linux + * brand icon (CC BY 4.0, see THIRD_PARTY_NOTICES.md): the artwork's Tux is drawn + * as a fine-line illustration that reads as a different kind of drawing at this + * size, and the abstract flat penguin marks are not recognisable as Linux at all. + * + * Every mark renders at one width instead of one height: each view box is the + * mark's own ink box, measured with getBBox, given the height of the tallest mark + * in the set, so a four-pane square and an apple take the same room in a row + * without either being stretched to get there. + * + * Inline paths rather than masked asset files, because a mask whose asset does + * not resolve paints the element as a solid block instead of showing no mark. + * + * Shared by the desktop shell and mobile web: both surfaces name the same + * devices, so one device is drawn the same way on either. How large a mark is, + * what colour it takes and where it sits stay local to each surface. + */ + +export type DeviceSystemKey = 'windows' | 'macos' | 'linux' | 'harmonyos' | 'server'; + +/** + * `opticalShift` is how far a mark's own mass sits from the centre of its box, + * as a fraction of that box, measured by rasterising the mark and taking its + * centroid. A mark is not a rectangle: the Apple's leaf is thin above a heavy + * body, so its mass sits about 9% below centre and the mark reads as low beside + * a line of text. The set is small and reviewed, so each mark states its own + * correction (positive moves the mark down) instead of every caller carrying + * one. A mark whose mass is already centred keeps 0, and the lucide fallback + * glyph is centred by its own construction. + */ +export const DEVICE_SYSTEM_MARKS: Record< + DeviceSystemKey, + { viewBox: string; path: string; opticalShift: number } +> = { + windows: { + viewBox: '0 -2.7315 24 29.4629', + path: 'M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623', + opticalShift: 0, + }, + macos: { + viewBox: '2.225 0 19.55 24', + path: 'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701', + opticalShift: -0.085, + }, + linux: { + viewBox: '12.674 -3.5278 422.849 519.0985', + path: 'M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z', + opticalShift: 0, + }, + harmonyos: { + viewBox: '1.003 -1.5002 21.994 27.0003', + path: 'M1.861 0H3.59v3.548h3.861V0H9.19v8.883H7.458V5.136H3.59v3.746H1.858Zm8.248 8.883ZM13.854 0h1.706l2.809 4.7h.1L21.278 0h1.719v8.883h-1.719v-4.38l.1-1.489h-.1l-2.334 3.983h-1.039l-2.347-3.983h-.1l.1 1.489v4.38h-1.706Zm4.702 21.648a4.082 4.082 0 0 1-1.154-.161 3.417 3.417 0 0 1-1.01-.484 3.5 3.5 0 0 1-.8-.782 3.817 3.817 0 0 1-.538-1.092l1.666-.62a2.411 2.411 0 0 0 .643 1.116 1.683 1.683 0 0 0 1.207.434 2.173 2.173 0 0 0 .524-.062 1.749 1.749 0 0 0 .459-.2 1.02 1.02 0 0 0 .328-.335.88.88 0 0 0 .118-.459 1.052 1.052 0 0 0-.092-.447 1.031 1.031 0 0 0-.315-.373 2.538 2.538 0 0 0-.564-.335 8.135 8.135 0 0 0-.852-.335l-.577-.2a4.753 4.753 0 0 1-.774-.335 3.44 3.44 0 0 1-.7-.509 2.662 2.662 0 0 1-.525-.695 2.093 2.093 0 0 1-.2-.918 2.248 2.248 0 0 1 .21-.968 2.433 2.433 0 0 1 .616-.794 2.87 2.87 0 0 1 .957-.533 3.726 3.726 0 0 1 1.246-.2 3.57 3.57 0 0 1 1.22.186 2.783 2.783 0 0 1 .879.459 2.468 2.468 0 0 1 .59.608 2.9 2.9 0 0 1 .328.633l-1.56.62a1.55 1.55 0 0 0-.485-.67 1.387 1.387 0 0 0-.944-.3 1.655 1.655 0 0 0-.957.261.754.754 0 0 0-.38.658.843.843 0 0 0 .367.682 4.232 4.232 0 0 0 1.167.534l.59.186a6.271 6.271 0 0 1 1.023.434 2.948 2.948 0 0 1 .8.57 2.191 2.191 0 0 1 .511.769 2.44 2.44 0 0 1 .183.98 2.317 2.317 0 0 1-.3 1.2 2.559 2.559 0 0 1-.747.819 3.361 3.361 0 0 1-1.036.484 4.184 4.184 0 0 1-1.128.161Zm-13.028 0a4.441 4.441 0 0 1-3.23-1.34 4.757 4.757 0 0 1-.956-1.476 4.912 4.912 0 0 1-.339-1.824 4.813 4.813 0 0 1 .339-1.811 4.569 4.569 0 0 1 .956-1.477 4.38 4.38 0 0 1 1.427-.992 4.5 4.5 0 0 1 1.8-.36 4.417 4.417 0 0 1 1.79.36 4.343 4.343 0 0 1 1.44.992 4.418 4.418 0 0 1 .944 1.477 4.67 4.67 0 0 1 .351 1.811 4.765 4.765 0 0 1-.351 1.824 4.589 4.589 0 0 1-.944 1.476 4.495 4.495 0 0 1-3.23 1.34Zm0-1.588a2.822 2.822 0 0 0 1.125-.223 2.761 2.761 0 0 0 .92-.621 2.723 2.723 0 0 0 .617-.955 3.321 3.321 0 0 0 .23-1.253 3.227 3.227 0 0 0-.23-1.24 2.7 2.7 0 0 0-.617-.968 2.759 2.759 0 0 0-.92-.62 2.821 2.821 0 0 0-1.125-.223 2.856 2.856 0 0 0-2.057.844 2.946 2.946 0 0 0-.617.968 3.388 3.388 0 0 0-.218 1.24 3.488 3.488 0 0 0 .218 1.253 2.972 2.972 0 0 0 .617.955 2.856 2.856 0 0 0 2.057.843Zm4.972 1.389Zm-8.269 1.039h6.5V24h-6.5Z', + opticalShift: 0, + }, + server: { + viewBox: '0 -20.783 91.4844 112.3082', + path: 'M11.5234 70.7422C3.90625 70.7422 0 66.875 0 59.3359V11.4062C0 3.86719 3.90625 0 11.5234 0H79.9609C87.6172 0 91.4844 3.90625 91.4844 11.4062V59.3359C91.4844 66.8359 87.6172 70.7422 79.9609 70.7422H11.5234ZM4.84375 11.6797V22.1875H86.6406V11.6797C86.6406 7.14844 84.1797 4.84375 79.8828 4.84375H11.6016C7.22656 4.84375 4.84375 7.14844 4.84375 11.6797ZM72.6172 18.4375C70.0781 18.4766 67.8516 16.25 67.8516 13.7109C67.8516 11.0938 70.0781 8.94531 72.6172 8.94531C75.1562 8.94531 77.3047 11.0938 77.3047 13.7109C77.3047 16.25 75.1562 18.3984 72.6172 18.4375ZM4.84375 44.0625H86.6406V26.7578H4.84375V44.0625ZM72.6172 40.0781C70.0781 40.1172 67.8516 37.8906 67.8516 35.3516C67.8516 32.7344 70.0781 30.5859 72.6172 30.5859C75.1562 30.5859 77.3047 32.7344 77.3047 35.3516C77.3047 37.8906 75.1562 40.0391 72.6172 40.0781ZM11.6016 65.8984H79.8828C84.1797 65.8984 86.6406 63.5938 86.6406 59.0625V48.6328H4.84375V59.0625C4.84375 63.5938 7.22656 65.8984 11.6016 65.8984ZM72.6172 61.7188C70.0781 61.7578 67.8516 59.5312 67.8516 56.9922C67.8516 54.375 70.0781 52.1875 72.6172 52.1875C75.1562 52.1875 77.3047 54.375 77.3047 56.9922C77.3047 59.5312 75.1562 61.6797 72.6172 61.7188Z', + opticalShift: 0, + }, +}; + + +/** + * The system an os string names, or null when it cannot be placed. + * + * Hosts report `macOS`, `Windows`, `Linux` and `HarmonyOS`, but the same field is + * filled in by any client and by older Relays, so the match stays tolerant about + * casing and spelling. A system we cannot place answers null rather than a + * plausible guess, and each caller keeps the neutral mark it already drew. + */ +export function deviceSystemKeyFromOs(os: string | null | undefined): DeviceSystemKey | null { + const value = os?.trim().toLowerCase() ?? ''; + if (!value) return null; + if (value.includes('harmony') || value.includes('ohos')) return 'harmonyos'; + if (value.startsWith('win') || value.includes('windows')) return 'windows'; + if (value.includes('mac') || value.includes('darwin') || value.includes('osx')) return 'macos'; + if (value.includes('linux')) return 'linux'; + return null; +} diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 0aaeaf8301..e355060404 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1375,6 +1375,11 @@ $_section-header-height: 22px; > svg { flex-shrink: 0; + // A line box is split by the font's ascent and descent, not by the glyphs + // drawn in it, so centring the mark on that box leaves its ink about a pixel + // above the text's own ink centre. One pixel, measured in the browser, puts + // the two on one optical centre. + transform: translateY(1px); } &:hover, @@ -1402,15 +1407,17 @@ $_section-header-height: 22px; } // Attached remote parties are shown once per device kind with unframed icons. -// Message-app marks inherit the theme foreground; connection state is already -// communicated by the device overview rather than by recoloring the brand. +// One row of peers is one set of marks, so they all take the theme foreground: a +// brand, system or kind silhouette drawn a step quieter reads as a disabled or +// offline party instead of as another drawing. Connection state is communicated +// by the device overview, never by recoloring a mark. .openbitfun-nav-panel__footer-device-status-attached { display: flex; flex-shrink: 0; align-items: center; gap: 4px; margin-left: 1px; - color: var(--openbitfun-color-content-secondary); + color: var(--openbitfun-color-content-primary); } .openbitfun-nav-panel__footer-device-status-attached-group { @@ -1426,7 +1433,6 @@ $_section-header-height: 22px; &[data-openbitfun-device-kind='message-app'] { border: 0; background: transparent; - color: var(--openbitfun-color-content-primary); } } diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx index e6302ce21e..887d0c101d 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx @@ -334,6 +334,111 @@ describe('device status card', () => { expect(element('nav-footer-device-status').getAttribute('aria-label')).toContain(name); }); + it('draws this machine as the attached desktop, by the system it reported', () => { + const footer = () => element('nav-footer-device-status'); + const attachedDesktop = () => footer() + .querySelector('[data-openbitfun-device-kind="desktop"]'); + + // A peer in use names itself in the trigger and lands in the carousel; this + // machine is the attached desktop, and it reads as the Mac it is. + state.overview = overview({ + localDeviceName: 'Workstation', + localDeviceOs: 'macOS', + peer: { deviceId: 'peer-1', deviceName: 'Windows box' }, + peerDeviceOs: 'Windows', + }); + render(); + expect(footer().textContent).toContain('Windows box'); + expect(attachedDesktop()?.querySelector('svg[data-system="macos"]')).not.toBeNull(); + // The peer's own system never stands in for this machine's. + expect(attachedDesktop()?.querySelector('svg[data-system="windows"]')).toBeNull(); + + // A headless client draws the server silhouette wherever it runs. + state.overview = overview({ + localDeviceName: 'Workstation', + localDeviceOs: 'Linux', + localDeviceKind: 'cli', + peer: { deviceId: 'peer-1', deviceName: 'Windows box' }, + }); + render(); + expect(attachedDesktop()?.querySelector('svg[data-system="server"]')).not.toBeNull(); + + // A phone stays a phone: only the desktop group is this machine. + state.overview = overview({ + localDeviceName: 'Workstation', + localDeviceOs: 'macOS', + peer: { deviceId: 'peer-1', deviceName: 'Windows box' }, + remoteStatus: { + relay_connected: true, + relay_url: 'http://192.168.1.2:9700', + active_method: 'lan', + clients: [{ id: 'mobile-user', name: 'My phone' }], + bot_connected: null, + bot_verbose_mode: false, + }, + }); + render(); + expect(footer().querySelector('[data-openbitfun-device-kind="mobile"] [data-system]')).toBeNull(); + + // A system this client cannot place keeps the neutral mark it always drew. + state.overview = overview({ + localDeviceName: 'Workstation', + localDeviceOs: 'FreeBSD', + peer: { deviceId: 'peer-1', deviceName: 'Windows box' }, + }); + render(); + expect(attachedDesktop()?.querySelector('[data-system]')).toBeNull(); + expect(attachedDesktop()?.querySelector('svg')).not.toBeNull(); + }); + + it('marks a device name with the system that device runs', () => { + state.overview = overview({ + localDeviceName: 'Workstation', + localDeviceOs: 'macOS', + peer: { deviceId: 'peer-1', deviceName: 'lwb_server' }, + peerDeviceKind: 'cli', + remoteStatus: { + relay_connected: true, + relay_url: 'http://192.168.1.2:9700', + active_method: 'lan', + clients: [{ id: 'mobile-user', name: 'My phone' }], + bot_connected: 'weixin', + bot_verbose_mode: false, + }, + }); + render(); + + // A host row answers with the system it runs; a phone and a chat app keep + // saying what they are, because neither is a system this client can draw. + const rows = element('nav-device-status-connected-devices'); + const row = (text: string) => Array.from( + rows.querySelectorAll('.openbitfun-device-overview__device-row'), + ).find(candidate => candidate.textContent?.includes(text))!; + expect(row('Workstation').querySelector('svg[data-system="macos"]')).not.toBeNull(); + expect(row('My phone').querySelector('[data-system]')).toBeNull(); + expect(row('My phone').querySelector('svg')).not.toBeNull(); + expect(row('remoteConnect.weixin').querySelector('[data-system]')).toBeNull(); + expect(row('remoteConnect.weixin').querySelector('svg')).not.toBeNull(); + + // The trigger names one device and states its system, whichever end of the + // connection that device sits on: the peer here, this machine below. + const trigger = () => element('nav-footer-device-status'); + const triggerMark = () => trigger().querySelector('svg'); + expect(trigger().textContent).toContain('lwb_server'); + expect(triggerMark()?.getAttribute('data-system')).toBe('server'); + + state.overview = overview({ localDeviceName: 'Workstation', localDeviceOs: 'macOS' }); + render(); + expect(trigger().textContent).toContain('Workstation'); + expect(trigger().querySelector('svg[data-system="macos"]')).not.toBeNull(); + + // A system this client cannot place keeps the neutral mark, never a guess. + state.overview = overview({ localDeviceName: 'Workstation', localDeviceOs: 'FreeBSD' }); + render(); + expect(trigger().querySelector('[data-system]')).toBeNull(); + expect(triggerMark()).not.toBeNull(); + }); + it('keeps an incompatible peer in the switch list but never connects to it', async () => { state.identity = { status: 'signed-in', me: { user: { accountId: 'acct', githubId: 42 } } }; state.getDeviceInfo.mockResolvedValue({ device_id: 'local', device_name: 'This computer' }); diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index 31351430aa..e64d0b3ede 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -25,6 +25,7 @@ import { } from '../deviceInterconnectionOverview'; import { useDeviceInterconnectionOverview } from './useDeviceInterconnectionOverview'; import { DeviceArtwork } from './DeviceArtwork'; +import { DeviceSystemGlyph } from './DeviceSystemGlyph'; interface DeviceStatusControlProps { open: boolean; @@ -68,6 +69,27 @@ function DeviceIcon({ } } +/** + * The mark in front of a device's name. A host answers with the system it runs, + * the same mark the device list draws. A phone and a chat app are the two kinds + * that are not a system — one is drawn as a phone and one as its brand — so they + * keep the silhouette that says what they are. + */ +function DeviceMark({ + device, + identity, + size = 'sm', +}: { + device: DeviceOverviewDevice; + identity?: string | null; + size?: IconSize; +}) { + if (device.kind === 'mobile' || device.kind === 'message-app') { + return ; + } + return ; +} + const DeviceStatusControl: React.FC = ({ open, onOpenChange, @@ -160,6 +182,17 @@ const DeviceStatusControl: React.FC = ({ overview, refresh, } = useDeviceInterconnectionOverview(localDeviceLabel, t('remoteConnect.mobileBrowserTitle')); + /** + * This machine as the overview sees it: its own entry while a peer is in use, + * the primary device while none is. The attached cluster draws its system + * instead of a generic kind icon, so a controller reads as the device it is, + * the way the same device reads in the device list. + */ + const thisMachine = useMemo(() => ( + overview.primaryDevice.local + ? overview.primaryDevice + : overview.devices.find(device => device.local) ?? overview.primaryDevice + ), [overview]); /** * A device says what it is by the kind it reported to the Relay, so the * account directory is enough for any device. A live control link can say @@ -326,7 +359,10 @@ const DeviceStatusControl: React.FC = ({ data-openbitfun-part="deviceStatus" data-openbitfun-state={overview.mode} > - + {/* The label names a device, and this mark says which system that device + is: the machine the window works on, whichever end of the connection + it sits on. */} + {overview.currentWorkDeviceName} @@ -341,11 +377,21 @@ const DeviceStatusControl: React.FC = ({ data-openbitfun-device-kind={group.kind} key={group.kind} > - + {group.kind === 'desktop' ? ( + // The only desktop a client can be attached to is itself: the + // overview marks this machine's own entry as the controlling + // one, and a peer is the device being used instead of an + // attached one. It draws the system this machine reported, so + // the group names the same device the list does, rather than a + // second generic monitor. + + ) : ( + + )} {group.count > 1 && ( {group.count} @@ -406,9 +452,9 @@ const DeviceStatusControl: React.FC = ({ data-openbitfun-activities={device.activities.join(' ')} > diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceSystemGlyph.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceSystemGlyph.tsx index 8c6d83824a..7b06c80003 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceSystemGlyph.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceSystemGlyph.tsx @@ -1,10 +1,19 @@ import { Monitor } from 'lucide-react'; +import type { IconSize } from '@openbitfun/ui'; import type { DeviceOverviewDevice } from '../deviceInterconnectionOverview'; import { getDeviceArtworkKind } from './deviceArtworkKind'; import { DEVICE_MARK_BY_ARTWORK, DEVICE_SYSTEM_MARKS } from './deviceSystemMarks'; type DeviceSystemFacts = Pick; +const ICON_TOKEN_SIZE: Record = { + '2xs': 'var(--openbitfun-control-icon-size2xs)', + xs: 'var(--openbitfun-control-icon-size-xs)', + sm: 'var(--openbitfun-control-icon-size-sm)', + md: 'var(--openbitfun-control-icon-size-md)', + lg: 'var(--openbitfun-control-icon-size-lg)', +}; + /** * The mark a device row shows in front of its name: the system the device * reported, the server silhouette for a CLI host, and the neutral monitor this @@ -13,20 +22,39 @@ type DeviceSystemFacts = Pick; + if (!key) return ; const mark = DEVICE_SYSTEM_MARKS[key]; return ( diff --git a/src/web-ui/src/app/components/NavPanel/components/deviceArtworkKind.ts b/src/web-ui/src/app/components/NavPanel/components/deviceArtworkKind.ts index 9fc768c72a..262e7bd3ab 100644 --- a/src/web-ui/src/app/components/NavPanel/components/deviceArtworkKind.ts +++ b/src/web-ui/src/app/components/NavPanel/components/deviceArtworkKind.ts @@ -1,3 +1,4 @@ +import { deviceSystemKeyFromOs } from '../../../../../../shared/device-system/deviceSystemMarks'; import type { DeviceOverviewDevice } from '../deviceInterconnectionOverview'; export type DeviceArtworkKind = @@ -12,23 +13,10 @@ export type DeviceArtworkKind = type DeviceArtworkFacts = Pick; /** - * Artwork for the system a device reported. - * - * OpenBitFun hosts report `macOS`, `Windows`, `Linux` and `HarmonyOS`, but the - * same field is filled in by any client and by older Relays, so the match stays - * tolerant about casing and spelling. A system we cannot place keeps the - * neutral artwork instead of claiming a plausible one. + * Artwork for the system a device reported. A system we cannot place keeps the + * neutral artwork instead of claiming a plausible one, so the shared resolver + * answers only for the systems it knows. */ -function systemArtworkKind(os: string | null | undefined): DeviceArtworkKind { - const value = os?.trim().toLowerCase() ?? ''; - if (!value) return 'device'; - if (value.includes('harmony') || value.includes('ohos')) return 'harmonyos'; - if (value.startsWith('win') || value.includes('windows')) return 'windows'; - if (value.includes('mac') || value.includes('darwin') || value.includes('osx')) return 'macos'; - if (value.includes('linux')) return 'linux'; - return 'device'; -} - export function getDeviceArtworkKind(device: DeviceArtworkFacts): DeviceArtworkKind { // A CLI host has no window to draw, whatever machine it runs on. if (device.hostKind === 'cli') return 'server'; @@ -37,5 +25,5 @@ export function getDeviceArtworkKind(device: DeviceArtworkFacts): DeviceArtworkK // Device names can identify a model, but the controller's OS cannot identify // a remote machine. Unrecognized names deliberately use neutral artwork. if (/\bmacbook[\s._-]*air\b/i.test(device.name)) return 'macbook-air'; - return systemArtworkKind(device.os); + return deviceSystemKeyFromOs(device.os) ?? 'device'; } diff --git a/src/web-ui/src/app/components/NavPanel/components/deviceSystemMarks.ts b/src/web-ui/src/app/components/NavPanel/components/deviceSystemMarks.ts index 6252fc5ede..618f09e8fd 100644 --- a/src/web-ui/src/app/components/NavPanel/components/deviceSystemMarks.ts +++ b/src/web-ui/src/app/components/NavPanel/components/deviceSystemMarks.ts @@ -1,46 +1,9 @@ import type { DeviceArtworkKind } from './deviceArtworkKind'; +import type { DeviceSystemKey } from '../../../../../../shared/device-system/deviceSystemMarks'; -/** - * Monochrome contours of the systems a device can report, for a row-sized mark. - * Windows, macOS and HarmonyOS are the CC0 simple-icons vectors the large device - * artwork already draws, and `server` is that artwork's own server silhouette. - * The Linux mark is the Font Awesome Linux brand icon (CC BY 4.0, see - * THIRD_PARTY_NOTICES.md): the artwork's Tux is drawn as a fine-line illustration - * that reads as a different kind of drawing at this size, and the abstract flat - * penguin marks are not recognisable as Linux at all. - * - * Every mark renders at one width instead of one height: each view box is the - * mark's own ink box, measured with getBBox, given the height of the tallest - * mark in the set. A four-pane square and an apple mark therefore take the same - * room in a row, and neither is stretched to get there. - * - * Inline paths rather than masked asset files, because a mask whose asset does - * not resolve paints the element as a solid block instead of showing no mark. - */ -export type DeviceSystemKey = 'windows' | 'macos' | 'linux' | 'harmonyos' | 'server'; - -export const DEVICE_SYSTEM_MARKS: Record = { - windows: { - viewBox: '0 -2.7315 24 29.4629', - path: 'M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623', - }, - macos: { - viewBox: '2.225 0 19.55 24', - path: 'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701', - }, - linux: { - viewBox: '12.674 -3.5278 422.849 519.0985', - path: 'M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z', - }, - harmonyos: { - viewBox: '1.003 -1.5002 21.994 27.0003', - path: 'M1.861 0H3.59v3.548h3.861V0H9.19v8.883H7.458V5.136H3.59v3.746H1.858Zm8.248 8.883ZM13.854 0h1.706l2.809 4.7h.1L21.278 0h1.719v8.883h-1.719v-4.38l.1-1.489h-.1l-2.334 3.983h-1.039l-2.347-3.983h-.1l.1 1.489v4.38h-1.706Zm4.702 21.648a4.082 4.082 0 0 1-1.154-.161 3.417 3.417 0 0 1-1.01-.484 3.5 3.5 0 0 1-.8-.782 3.817 3.817 0 0 1-.538-1.092l1.666-.62a2.411 2.411 0 0 0 .643 1.116 1.683 1.683 0 0 0 1.207.434 2.173 2.173 0 0 0 .524-.062 1.749 1.749 0 0 0 .459-.2 1.02 1.02 0 0 0 .328-.335.88.88 0 0 0 .118-.459 1.052 1.052 0 0 0-.092-.447 1.031 1.031 0 0 0-.315-.373 2.538 2.538 0 0 0-.564-.335 8.135 8.135 0 0 0-.852-.335l-.577-.2a4.753 4.753 0 0 1-.774-.335 3.44 3.44 0 0 1-.7-.509 2.662 2.662 0 0 1-.525-.695 2.093 2.093 0 0 1-.2-.918 2.248 2.248 0 0 1 .21-.968 2.433 2.433 0 0 1 .616-.794 2.87 2.87 0 0 1 .957-.533 3.726 3.726 0 0 1 1.246-.2 3.57 3.57 0 0 1 1.22.186 2.783 2.783 0 0 1 .879.459 2.468 2.468 0 0 1 .59.608 2.9 2.9 0 0 1 .328.633l-1.56.62a1.55 1.55 0 0 0-.485-.67 1.387 1.387 0 0 0-.944-.3 1.655 1.655 0 0 0-.957.261.754.754 0 0 0-.38.658.843.843 0 0 0 .367.682 4.232 4.232 0 0 0 1.167.534l.59.186a6.271 6.271 0 0 1 1.023.434 2.948 2.948 0 0 1 .8.57 2.191 2.191 0 0 1 .511.769 2.44 2.44 0 0 1 .183.98 2.317 2.317 0 0 1-.3 1.2 2.559 2.559 0 0 1-.747.819 3.361 3.361 0 0 1-1.036.484 4.184 4.184 0 0 1-1.128.161Zm-13.028 0a4.441 4.441 0 0 1-3.23-1.34 4.757 4.757 0 0 1-.956-1.476 4.912 4.912 0 0 1-.339-1.824 4.813 4.813 0 0 1 .339-1.811 4.569 4.569 0 0 1 .956-1.477 4.38 4.38 0 0 1 1.427-.992 4.5 4.5 0 0 1 1.8-.36 4.417 4.417 0 0 1 1.79.36 4.343 4.343 0 0 1 1.44.992 4.418 4.418 0 0 1 .944 1.477 4.67 4.67 0 0 1 .351 1.811 4.765 4.765 0 0 1-.351 1.824 4.589 4.589 0 0 1-.944 1.476 4.495 4.495 0 0 1-3.23 1.34Zm0-1.588a2.822 2.822 0 0 0 1.125-.223 2.761 2.761 0 0 0 .92-.621 2.723 2.723 0 0 0 .617-.955 3.321 3.321 0 0 0 .23-1.253 3.227 3.227 0 0 0-.23-1.24 2.7 2.7 0 0 0-.617-.968 2.759 2.759 0 0 0-.92-.62 2.821 2.821 0 0 0-1.125-.223 2.856 2.856 0 0 0-2.057.844 2.946 2.946 0 0 0-.617.968 3.388 3.388 0 0 0-.218 1.24 3.488 3.488 0 0 0 .218 1.253 2.972 2.972 0 0 0 .617.955 2.856 2.856 0 0 0 2.057.843Zm4.972 1.389Zm-8.269 1.039h6.5V24h-6.5Z', - }, - server: { - viewBox: '0 -20.783 91.4844 112.3082', - path: 'M11.5234 70.7422C3.90625 70.7422 0 66.875 0 59.3359V11.4062C0 3.86719 3.90625 0 11.5234 0H79.9609C87.6172 0 91.4844 3.90625 91.4844 11.4062V59.3359C91.4844 66.8359 87.6172 70.7422 79.9609 70.7422H11.5234ZM4.84375 11.6797V22.1875H86.6406V11.6797C86.6406 7.14844 84.1797 4.84375 79.8828 4.84375H11.6016C7.22656 4.84375 4.84375 7.14844 4.84375 11.6797ZM72.6172 18.4375C70.0781 18.4766 67.8516 16.25 67.8516 13.7109C67.8516 11.0938 70.0781 8.94531 72.6172 8.94531C75.1562 8.94531 77.3047 11.0938 77.3047 13.7109C77.3047 16.25 75.1562 18.3984 72.6172 18.4375ZM4.84375 44.0625H86.6406V26.7578H4.84375V44.0625ZM72.6172 40.0781C70.0781 40.1172 67.8516 37.8906 67.8516 35.3516C67.8516 32.7344 70.0781 30.5859 72.6172 30.5859C75.1562 30.5859 77.3047 32.7344 77.3047 35.3516C77.3047 37.8906 75.1562 40.0391 72.6172 40.0781ZM11.6016 65.8984H79.8828C84.1797 65.8984 86.6406 63.5938 86.6406 59.0625V48.6328H4.84375V59.0625C4.84375 63.5938 7.22656 65.8984 11.6016 65.8984ZM72.6172 61.7188C70.0781 61.7578 67.8516 59.5312 67.8516 56.9922C67.8516 54.375 70.0781 52.1875 72.6172 52.1875C75.1562 52.1875 77.3047 54.375 77.3047 56.9922C77.3047 59.5312 75.1562 61.6797 72.6172 61.7188Z', - }, -}; +// The systems a host can report are shared with mobile web so both surfaces +// draw one device the same way. The artwork kinds below are this shell's own. +export { DEVICE_SYSTEM_MARKS, deviceSystemKeyFromOs, type DeviceSystemKey } from '../../../../../../shared/device-system/deviceSystemMarks'; /** * Artwork a device resolved to, and the mark that stands for it. A MacBook Air diff --git a/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.test.tsx b/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.test.tsx new file mode 100644 index 0000000000..4886139637 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; +import { + useDeviceInterconnectionOverview, +} from './useDeviceInterconnectionOverview'; +import type { DeviceInterconnectionOverview } from '../deviceInterconnectionOverview'; + +const state = vi.hoisted(() => ({ + deviceName: 'Workstation' as string | null, + peer: null as { deviceId: string; deviceName: string } | null, +})); + +vi.mock('@/infrastructure/account/useAccountLoginState', () => ({ + useAccountLoginState: () => ({ loggedIn: true, deviceName: state.deviceName }), +})); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { listen: () => () => {} }, +})); +vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async importOriginal => ({ + ...await importOriginal(), + remoteConnectAPI: { + // Identity only: this command states no system and no kind for any client. + getDeviceInfo: async () => ({ + device_id: 'local', device_name: 'Workstation', mac_address: '', + }), + accountGetCredentialHint: async () => ({ relay_url: null }), + }, +})); +vi.mock('@/infrastructure/remote-connect/remoteConnectStatus', () => ({ + remoteConnectStatusSource: { refresh: async () => undefined }, + useRemoteConnectStatus: () => ({ + status: { + relay_connected: false, + relay_url: null, + active_method: null, + clients: [], + bot_connected: null, + bot_verbose_mode: false, + }, + state: 'ready', + }), +})); +vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ + usePeerDeviceModeOptional: () => (state.peer + ? { peerMode: { active: true, ...state.peer }, attachments: [] } + : undefined), +})); +vi.mock('@/features/dispatch/dispatchJobStore', () => ({ + useDispatchJobStore: (selector: (value: { jobs: Record }) => unknown) => selector({ jobs: {} }), +})); + +let root: Root; +let container: HTMLDivElement; +let rendered: DeviceInterconnectionOverview | null = null; + +function Probe() { + const { overview } = useDeviceInterconnectionOverview('Fallback device'); + rendered = overview; + return null; +} + +beforeEach(() => { + vi.clearAllMocks(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + state.deviceName = 'Workstation'; + state.peer = null; + rendered = null; + useDeviceDirectory.setState({ devices: [], localId: null }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + useDeviceDirectory.setState({ devices: [], localId: null }); +}); + +async function render() { + await act(async () => { root.render(); }); + // The hook reads through one refresh pass before it can project anything. + await act(async () => { await Promise.resolve(); }); + return rendered!; +} + +const directoryRow = (overrides: Record) => ({ + device_id: 'local', device_name: 'Workstation', online: true, ...overrides, +}); + +describe('device interconnection overview inputs', () => { + it('takes this machine\'s system and kind from the account directory row', async () => { + // The device info call answers with identity only, so a system can only come + // from the row this client reported to the Relay — the same row the device + // list draws, never the browser's own platform. + useDeviceDirectory.setState({ + devices: [directoryRow({ device_os: 'macOS 15.7.3', device_kind: 'desktop' })] as never, + localId: 'local', + }); + + const overview = await render(); + expect(overview.primaryDevice.local).toBe(true); + expect(overview.primaryDevice.os).toBe('macOS 15.7.3'); + expect(overview.primaryDevice.hostKind).toBe('desktop'); + }); + + it('keeps this machine\'s own facts while a peer is the device in use', async () => { + useDeviceDirectory.setState({ + devices: [directoryRow({ + device_os: 'macOS 15.7.3', device_kind: 'cli', device_alias: 'lwb_macbook', + })] as never, + localId: 'local', + }); + state.peer = { deviceId: 'peer-1', deviceName: 'Windows box' }; + + const overview = await render(); + expect(overview.primaryDevice.name).toBe('Windows box'); + const mac = overview.devices.find(device => device.local); + expect(mac?.os).toBe('macOS 15.7.3'); + expect(mac?.hostKind).toBe('cli'); + }); + + it('states no system at all when no directory row has arrived', async () => { + // An unloaded directory is not evidence of a system, and the browser's own + // platform is not this machine's: the neutral mark is the honest answer + // rather than a plausible guess. + const overview = await render(); + expect(overview.primaryDevice.os).toBeNull(); + expect(overview.primaryDevice.hostKind).toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts b/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts index 89b87b853c..e3e78cdbcd 100644 --- a/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts +++ b/src/web-ui/src/app/components/NavPanel/components/useDeviceInterconnectionOverview.ts @@ -125,10 +125,16 @@ export function useDeviceInterconnectionOverview(fallbackLocalDeviceName: string : null ), [peerContext?.peerMode]); - // The peer's system and kind are facts only the account directory holds; the - // local ones come from this machine's own device info, never from the browser. - const localDeviceOs = localDevice?.device_os ?? null; - const localDeviceKind = localDevice?.device_kind ?? null; + // A system and a kind are facts a device reports to the Relay, so the account + // directory row is where they are stated for every device. That holds for this + // machine too: the device info call answers with identity only, and the row is + // the same one the device list draws. Never the browser's own platform, which + // identifies the window rather than the machine. + const localEntry = directory.localId + ? directory.devices.find(device => device.device_id === directory.localId) + : undefined; + const localDeviceOs = localEntry?.device_os ?? null; + const localDeviceKind = localEntry?.device_kind ?? null; const peerEntry = peer ? directory.devices.find(device => device.device_id === peer.deviceId) : undefined; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts index 3ba3386a18..83d034e3e4 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts @@ -146,6 +146,10 @@ describe('Remote Connect safety contracts', () => { navPanelStyleSource.indexOf("&[data-openbitfun-device-kind='message-app'] {"), navPanelStyleSource.indexOf('.openbitfun-nav-panel__footer-device-status-attached-count'), ); + const footerBrandColorStyle = navPanelStyleSource.slice( + navPanelStyleSource.indexOf('.openbitfun-nav-panel__footer-device-status-attached {'), + navPanelStyleSource.indexOf('.openbitfun-nav-panel__footer-device-status-attached-group'), + ); const overviewMessageBrandStart = navPanelStyleSource.indexOf( "&[data-openbitfun-device-kind='message-app'] .openbitfun-device-overview__device-icon {", ); @@ -167,7 +171,7 @@ describe('Remote Connect safety contracts', () => { expect(connectedBrandStyle).not.toContain('background:'); expect(footerMessageBrandStyle).toContain('border: 0'); expect(footerMessageBrandStyle).toContain('background: transparent'); - expect(footerMessageBrandStyle).toContain('--openbitfun-color-content-primary'); + expect(footerBrandColorStyle).toContain('--openbitfun-color-content-primary'); expect(overviewMessageBrandStyle).toContain('background: transparent'); expect(overviewMessageBrandStyle).toContain('--openbitfun-color-content-primary'); expect(dialogSource).not.toContain(''); From fd80536ca21c342d52d1e330532c923d8fc23e54 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 21 Sep 2026 10:35:28 +0800 Subject: [PATCH 2/3] feat(mobile-web): draw devices with the shared system marks The devices page drew one monitor for every device, and the compact sidebar guessed a device's shape from its own name, so a Linux desktop and a headless host each read as whatever they happened to be called. Both now draw the mark the desktop shell draws, from the same shared table, with this surface's own sizes and colours: the devices page keeps its 20px leading slot and the sidebar its 22px. - RelayDeviceInfo now declares device_kind, which the Relay already sends. It stays optional, so an older Relay or client keeps the neutral mark. - A headless host draws the server silhouette whatever it runs; a device that reports no system this client can place keeps the monitor, and a phone keeps the phone. - The devices page moves "edit name" from a block under every row to an icon button on the row's own line. A row is a button and cannot hold another one, so the action sits beside it rather than inside it. - The model and system line ellipsizes, and moves to its own line when it does not fit beside the name, instead of squeezing the name at phone widths. Co-authored-by: bitfun-ai <318544290+bitfun-ai@users.noreply.github.com> --- .../src/components/CompactSettingsSheet.tsx | 4 +- .../src/components/DeviceSystemMark.tsx | 53 +++++++++++++++++++ src/mobile-web/src/pages/DevicesPage.tsx | 28 ++++++---- src/mobile-web/src/pages/SessionListPage.tsx | 24 ++------- .../src/services/RelayHttpClient.ts | 6 +++ .../src/styles/components/devices.scss | 37 +++++++++++-- 6 files changed, 115 insertions(+), 37 deletions(-) create mode 100644 src/mobile-web/src/components/DeviceSystemMark.tsx diff --git a/src/mobile-web/src/components/CompactSettingsSheet.tsx b/src/mobile-web/src/components/CompactSettingsSheet.tsx index e99988ec34..1e616e20d1 100644 --- a/src/mobile-web/src/components/CompactSettingsSheet.tsx +++ b/src/mobile-web/src/components/CompactSettingsSheet.tsx @@ -34,7 +34,7 @@ interface CompactSettingsSheetProps { onSelectDevice: (device: SettingsDevice) => void; onToggleTheme: () => void; open: boolean; - renderDeviceIcon: (name: string) => React.ReactNode; + renderDeviceIcon: (device: SettingsDevice) => React.ReactNode; selectedDeviceId: string | null; } @@ -140,7 +140,7 @@ export default function CompactSettingsSheet({ disabled={!device.online || !controllable} key={device.device_id} label={deviceDisplayName(device)} - leading={{renderDeviceIcon(deviceDisplayName(device))}} + leading={{renderDeviceIcon(device)}} onClick={() => onSelectDevice(device)} selected={current} supportingText={!controllable diff --git a/src/mobile-web/src/components/DeviceSystemMark.tsx b/src/mobile-web/src/components/DeviceSystemMark.tsx new file mode 100644 index 0000000000..1995ba3887 --- /dev/null +++ b/src/mobile-web/src/components/DeviceSystemMark.tsx @@ -0,0 +1,53 @@ +import { Monitor, Smartphone } from 'lucide-react'; +import { + DEVICE_SYSTEM_MARKS, + deviceSystemKeyFromOs, + type DeviceSystemKey, +} from '../../../shared/device-system/deviceSystemMarks'; + +interface DeviceSystemMarkProps { + /** Kind the device reported: a headless host draws the server silhouette. */ + deviceKind?: string | null; + /** System the device reported. Absent system keeps a neutral mark. */ + os?: string | null; + size: number; +} + +/** + * The mark in front of a device name: the system that device reported, the server + * silhouette for a headless host, and the shape that says what a device is when + * it reports no system this client can draw. + * + * The marks themselves are shared with the desktop shell, so one device is drawn + * the same way on either surface. Size and colour are the caller's: mobile web + * sets its own icon scale, and every mark takes the colour of the row it sits in. + */ +export function DeviceSystemMark({ deviceKind, os, size }: DeviceSystemMarkProps) { + const kind = deviceKind?.trim().toLowerCase(); + // A phone is not a system and a headless host has no window to draw, whatever + // either of them runs. + if (kind === 'mobile') { + return