From afe8149988c3fa1dd377a6e21375fe8199e8d37c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 2 Sep 2026 13:40:18 +0800 Subject: [PATCH] fix avator --- apps/mobile/app/bot-settings.tsx | 38 +++- apps/web/e2e/avatar-motion.spec.ts | 19 +- apps/web/e2e/bot-crud.spec.ts | 7 + apps/web/e2e/fixtures/avatar-motion.tsx | 31 +++- apps/web/e2e/localization.spec.ts | 48 +++++ apps/web/src/components/BotAvatarEditor.tsx | 57 ++++-- .../beautiful-ui/CollaborationMarker.test.tsx | 7 +- .../beautiful-ui/CollaborationMarker.tsx | 20 ++- .../components/beautiful-ui/beautiful-ui.css | 80 +++++++++ apps/web/src/pages/Welcome.tsx | 35 ++-- infra/sandboxes/computer/Dockerfile | 9 +- infra/sandboxes/computer/control.py | 3 + infra/sandboxes/computer/fluxbox.menu | 2 +- infra/sandboxes/computer/paste-text.sh | 22 +++ infra/sandboxes/computer/start.sh | 2 +- infra/sandboxes/computer/terminal.sh | 13 ++ .../supervisor/src/computer-spec.test.ts | 12 ++ .../sandboxes/supervisor/src/computer-spec.ts | 4 +- .../supervisor/src/home-ownership.test.ts | 17 +- .../supervisor/src/home-ownership.ts | 18 ++ infra/sandboxes/supervisor/src/index.test.ts | 10 +- infra/sandboxes/supervisor/src/index.ts | 23 ++- .../supervisor/src/supervisor-logic.ts | 9 +- packages/core/src/avatar-shape.ts | 10 +- packages/ui-web/src/bot-avatar.test.tsx | 17 ++ packages/ui-web/src/bot-avatar.tsx | 2 + packages/ui-web/src/styles.css | 168 ++++-------------- 27 files changed, 489 insertions(+), 194 deletions(-) create mode 100644 infra/sandboxes/computer/paste-text.sh create mode 100644 infra/sandboxes/computer/terminal.sh diff --git a/apps/mobile/app/bot-settings.tsx b/apps/mobile/app/bot-settings.tsx index e24cb0f..a9c57fd 100644 --- a/apps/mobile/app/bot-settings.tsx +++ b/apps/mobile/app/bot-settings.tsx @@ -22,6 +22,17 @@ type BotSettingsRecord = MobileBot & { description?: string; }; +const AVATAR_SHAPE_LABELS: Record = { + circle: "Circle", + oval: "Oval", + "rounded-square": "Rounded square", + pill: "Pill", + triangle: "Triangle", + hexagon: "Hexagon", + cloud: "Cloud", + teardrop: "Teardrop", +}; + export default function BotSettingsScreen() { const router = useRouter(); const { botId } = useLocalSearchParams<{ botId: string }>(); @@ -57,12 +68,22 @@ export default function BotSettingsScreen() { avatarImageArtifactId?: string | null; }) { if (!botId || !bot || pending) return; + const previous = bot; + setBot({ + ...bot, + ...(patch.color !== undefined ? { color: patch.color } : {}), + ...(patch.avatarShape !== undefined ? { avatarShape: patch.avatarShape } : {}), + ...(patch.avatarImageArtifactId !== undefined + ? { hasAvatarImage: patch.avatarImageArtifactId !== null } + : {}), + }); setPending(true); setError(null); try { const next = await rpc("bots/update", { botId, ...patch }); setBot(next); } catch (err) { + setBot(previous); setError(err instanceof Error ? err.message : "Could not update avatar"); } finally { setPending(false); @@ -76,6 +97,7 @@ export default function BotSettingsScreen() { if (!file) return; setPending(true); setError(null); + const previous = bot; try { const artifact = await rpc<{ id: string }>("artifacts/create", { botId, @@ -83,12 +105,14 @@ export default function BotSettingsScreen() { mimeType: file.mimeType, contentBase64: file.contentBase64, }); + if (bot) setBot({ ...bot, hasAvatarImage: true }); const next = await rpc("bots/update", { botId, avatarImageArtifactId: artifact.id, }); setBot(next); } catch (err) { + if (previous) setBot(previous); setError(err instanceof Error ? err.message : "Could not update avatar"); } finally { setPending(false); @@ -172,7 +196,7 @@ export default function BotSettingsScreen() { } style={{ width: 52, - height: 52, + height: 68, borderRadius: 12, borderWidth: 1, borderColor: selected ? "#5A5A62" : "#26262A", @@ -186,8 +210,18 @@ export default function BotSettingsScreen() { identity={bot.id} size={28} shape={shape} - variant="robot" + variant="organic" /> + + {AVATAR_SHAPE_LABELS[shape]} + ); })} diff --git a/apps/web/e2e/avatar-motion.spec.ts b/apps/web/e2e/avatar-motion.spec.ts index c6a1039..b246836 100644 --- a/apps/web/e2e/avatar-motion.spec.ts +++ b/apps/web/e2e/avatar-motion.spec.ts @@ -4,9 +4,9 @@ test("organic avatar path stays still when reduced motion is enabled", async ({ await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/e2e/fixtures/avatar-motion.html"); - const avatar = page.locator(".rakazo-organic-avatar"); + const avatar = page.locator(".rakazo-organic-avatar").first(); await expect(avatar).toBeVisible(); - await expect(avatar.locator("animate")).toHaveCount(0); + await expect(page.locator(".rakazo-organic-avatar animate")).toHaveCount(0); const body = avatar.locator(".rakazo-organic-avatar-body-working"); const snapshot = () => @@ -22,3 +22,18 @@ test("organic avatar path stays still when reduced motion is enabled", async ({ expect(first.animationName).toBe("none"); expect(second).toEqual(first); }); + +test("working faces keep their gaze in the upper-left", async ({ page }) => { + await page.goto("/e2e/fixtures/avatar-motion.html"); + + const workingEyes = page.locator('[data-gaze="upper-left"]'); + await expect(workingEyes).toHaveCount(8); + await expect(workingEyes.first()).toHaveCSS("animation-name", "rakazo-organic-eyes-thinking"); + + const transform = await workingEyes.first().evaluate((eyes: SVGGElement) => { + const matrix = new DOMMatrixReadOnly(getComputedStyle(eyes).transform); + return { x: matrix.m41, y: matrix.m42 }; + }); + expect(transform.x).toBeLessThan(0); + expect(transform.y).toBeLessThan(0); +}); diff --git a/apps/web/e2e/bot-crud.spec.ts b/apps/web/e2e/bot-crud.spec.ts index 4a2802a..1056ec6 100644 --- a/apps/web/e2e/bot-crud.spec.ts +++ b/apps/web/e2e/bot-crud.spec.ts @@ -64,6 +64,13 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) = const settings = page.getByTestId("bot-settings"); const editor = settings.getByTestId("bot-avatar-editor"); await expect(editor).toBeVisible(); + await expect(editor.getByRole("button", { name: "Cloud" })).toBeVisible(); + await expect(editor.getByRole("button", { name: "Circle" })).toBeVisible(); + await expect(editor.getByRole("button", { name: "Rounded square" })).toBeVisible(); + await editor.getByRole("button", { name: "Cloud" }).click(); + await expect(page.locator("aside [data-shape='cloud']").first()).toBeVisible(); + await editor.getByRole("button", { name: "Rounded square" }).click(); + await expect(page.locator("aside [data-shape='rounded-square']").first()).toBeVisible(); await editor.getByRole("button", { name: "Hexagon" }).click(); await expect(page.locator("aside [data-shape='hexagon']").first()).toBeVisible(); await captureScreenshot(page, testInfo, "27a-bot-avatar"); diff --git a/apps/web/e2e/fixtures/avatar-motion.tsx b/apps/web/e2e/fixtures/avatar-motion.tsx index 7c4e294..2622f7b 100644 --- a/apps/web/e2e/fixtures/avatar-motion.tsx +++ b/apps/web/e2e/fixtures/avatar-motion.tsx @@ -1,12 +1,29 @@ +import { AVATAR_SHAPES } from "@rakazo/contracts"; import { BotAvatar } from "@rakazo/ui-web"; import { createRoot } from "react-dom/client"; createRoot(document.getElementById("root")!).render( - , +
+ {AVATAR_SHAPES.map((shape) => ( + + ))} +
, ); diff --git a/apps/web/e2e/localization.spec.ts b/apps/web/e2e/localization.spec.ts index e524106..f21c6eb 100644 --- a/apps/web/e2e/localization.spec.ts +++ b/apps/web/e2e/localization.spec.ts @@ -23,3 +23,51 @@ test("renders the sign-in screen in Traditional Chinese with Huninn", async ({ await captureScreenshot(page, testInfo, "traditional-chinese-sign-in"); }); + +test("keeps the Traditional Chinese welcome brand aligned on narrow screens", async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 320, height: 640 }); + await page.addInitScript(() => { + localStorage.setItem("rakazo.uiLocale", "zh-TW"); + }); + await page.route("**/api/auth/get-session", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "null" }), + ); + + await page.goto("/"); + + const mark = page.getByTestId("welcome-mark"); + const title = page.getByTestId("welcome-title"); + await expect(mark).toBeVisible(); + await expect(title).toHaveText("BangSo Bot"); + await expect(title).toHaveCSS("font-family", /Huninn/); + await expect(page.getByText("好幫手機器人", { exact: true })).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })), + ) + .toEqual({ clientWidth: 320, scrollWidth: 320 }); + + const markBox = await mark.boundingBox(); + expect(markBox?.width).toBe(markBox?.height); + await captureScreenshot(page, testInfo, "traditional-chinese-welcome-narrow"); +}); + +test("keeps the welcome wordmark shorter than its mark", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.route("**/api/auth/get-session", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "null" }), + ); + + await page.goto("/"); + + const markBox = await page.getByTestId("welcome-mark").boundingBox(); + const wordmarkBox = await page.getByTestId("welcome-wordmark").boundingBox(); + expect(markBox).not.toBeNull(); + expect(wordmarkBox).not.toBeNull(); + expect(wordmarkBox?.height).toBeLessThanOrEqual(markBox?.height ?? 0); +}); diff --git a/apps/web/src/components/BotAvatarEditor.tsx b/apps/web/src/components/BotAvatarEditor.tsx index 4165508..581c9c3 100644 --- a/apps/web/src/components/BotAvatarEditor.tsx +++ b/apps/web/src/components/BotAvatarEditor.tsx @@ -9,7 +9,7 @@ import { } from "@rakazo/contracts"; import { botAvatarImageSrc, inferAttachmentMimeType, nextGeneratedAvatarFace } from "@rakazo/core"; import { BotAvatar } from "@rakazo/ui-web"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { rpc } from "../lib/rpc"; export function BotFace({ @@ -69,6 +69,11 @@ export function BotAvatarEditor({ const fileRef = useRef(null); const [pending, setPending] = useState(false); const [error, setError] = useState(null); + const [preview, setPreview] = useState(bot); + + useEffect(() => { + setPreview(bot); + }, [bot]); async function apply(patch: { color?: string; @@ -76,11 +81,20 @@ export function BotAvatarEditor({ avatarImageArtifactId?: string | null; }) { if (pending) return; + const previous = preview; + setPreview({ + ...preview, + ...patch, + ...(patch.avatarImageArtifactId !== undefined + ? { hasAvatarImage: patch.avatarImageArtifactId !== null } + : {}), + }); setPending(true); setError(null); try { await onChange(patch); } catch { + setPreview(previous); setError(t`Couldn't update avatar`); } finally { setPending(false); @@ -99,6 +113,7 @@ export function BotAvatarEditor({ } setPending(true); setError(null); + const previous = preview; try { const artifact = await rpc.artifacts.create({ botId: bot.id, @@ -106,8 +121,10 @@ export function BotAvatarEditor({ mimeType, contentBase64: await readFileAsBase64(file), }); + setPreview({ ...preview, hasAvatarImage: true }); await onChange({ avatarImageArtifactId: artifact.id }); } catch { + setPreview(previous); setError(t`Couldn't update avatar`); } finally { setPending(false); @@ -116,16 +133,17 @@ export function BotAvatarEditor({ } const generated = nextGeneratedAvatarFace({ - shape: bot.avatarShape, - color: bot.color, + shape: preview.avatarShape, + color: preview.color, }); return (
- -
+ +
+ {t`Avatar shape`} {AVATAR_SHAPES.map((shape) => { - const selected = (bot.avatarShape ?? "circle") === shape && !bot.hasAvatarImage; + const selected = (preview.avatarShape ?? "circle") === shape && !preview.hasAvatarImage; return ( ); })} -
+
{BOT_COLORS.map((color) => { - const selected = bot.color.toLowerCase() === color.toLowerCase(); + const selected = preview.color.toLowerCase() === color.toLowerCase(); return ( -
+
); } diff --git a/infra/sandboxes/computer/Dockerfile b/infra/sandboxes/computer/Dockerfile index 82818e4..783d25d 100644 --- a/infra/sandboxes/computer/Dockerfile +++ b/infra/sandboxes/computer/Dockerfile @@ -32,6 +32,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ chromium \ fonts-liberation \ fonts-dejavu-core \ + fonts-noto-cjk \ + fonts-noto-color-emoji \ + xclip \ dbus-x11 \ xdg-utils \ && rm -rf /var/lib/apt/lists/* \ @@ -43,6 +46,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && ln -sfn /bin/true /usr/bin/xmessage ENV DISPLAY=:1 ENV HOME=/home/rakazo +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 WORKDIR /home/rakazo # --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would # otherwise leave these files unreadable to USER 1000 at runtime. @@ -50,6 +55,8 @@ COPY --chmod=755 start.sh /usr/local/bin/rakazo-computer COPY --chmod=755 control.py /usr/local/bin/rakazo-computer-control COPY --from=capture-builder /build/librakazo-xcapture.so /usr/local/lib/librakazo-xcapture.so COPY --chmod=755 rakazo-browser /usr/local/bin/rakazo-browser +COPY --chmod=755 paste-text.sh /usr/local/bin/rakazo-paste +COPY --chmod=755 terminal.sh /usr/local/bin/rakazo-terminal COPY --chmod=644 rakazo-browser.desktop /usr/share/applications/rakazo-browser.desktop COPY --chmod=644 fluxbox.init /etc/rakazo/fluxbox/init COPY --chmod=644 fluxbox.apps /etc/rakazo/fluxbox/apps @@ -58,7 +65,7 @@ COPY --chmod=644 embed.html /usr/share/novnc/embed.html COPY --chmod=644 clipboard-bridge.js /usr/share/novnc/clipboard-bridge.js # Strip CR so shebangs work even if the build context came from a CRLF checkout. RUN ldconfig \ - && sed -i 's/\r$//' /usr/local/bin/rakazo-computer /usr/local/bin/rakazo-computer-control /usr/local/bin/rakazo-browser \ + && sed -i 's/\r$//' /usr/local/bin/rakazo-computer /usr/local/bin/rakazo-computer-control /usr/local/bin/rakazo-browser /usr/local/bin/rakazo-paste /usr/local/bin/rakazo-terminal \ /etc/rakazo/fluxbox/init /etc/rakazo/fluxbox/apps /etc/rakazo/fluxbox/menu EXPOSE 7070 6080 6081 6082 6083 6084 6085 6086 6087 6088 6089 6090 6091 6092 6093 6094 6095 USER 1000:1000 diff --git a/infra/sandboxes/computer/control.py b/infra/sandboxes/computer/control.py index 30c1752..579fdd5 100644 --- a/infra/sandboxes/computer/control.py +++ b/infra/sandboxes/computer/control.py @@ -19,6 +19,7 @@ MAX_ARG_LEN = 16_384 KNOWN_LAUNCH = frozenset( { "rakazo-browser", + "rakazo-terminal", "xterm", } ) @@ -159,6 +160,8 @@ def allowed_control_argv(argv, display): if argv[0] != "env" or argv[1] != f"DISPLAY={display}": return False command = argv[2] + if command == "rakazo-paste": + return len(argv) == 4 if command == "xdotool": return allowed_xdotool_argv(argv) if command == "xdg-open": diff --git a/infra/sandboxes/computer/fluxbox.menu b/infra/sandboxes/computer/fluxbox.menu index 7845d81..c0794e9 100644 --- a/infra/sandboxes/computer/fluxbox.menu +++ b/infra/sandboxes/computer/fluxbox.menu @@ -1,4 +1,4 @@ [begin] (BangSo Bot) [exec] (Browser) {rakazo-browser} - [exec] (Terminal) {xterm -bg #111113 -fg #E8E8EA -cr #E8E8EA -title Terminal} + [exec] (Terminal) {rakazo-terminal} [end] diff --git a/infra/sandboxes/computer/paste-text.sh b/infra/sandboxes/computer/paste-text.sh new file mode 100644 index 0000000..729736f --- /dev/null +++ b/infra/sandboxes/computer/paste-text.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -ne 1 ]]; then + echo "usage: rakazo-paste TEXT" >&2 + exit 2 +fi + +printf '%s' "$1" | xclip -selection clipboard -in + +window_id="$(xdotool getactivewindow 2>/dev/null || true)" +window_class="$(xprop -id "$window_id" WM_CLASS 2>/dev/null || true)" +case "$window_class" in + *XTerm* | *xterm*) + printf '%s' "$1" | xclip -selection primary -in -loops 5 + sleep 0.1 + paste_key="shift+Insert" + ;; + *) paste_key="ctrl+v" ;; +esac + +xdotool key --clearmodifiers "$paste_key" diff --git a/infra/sandboxes/computer/start.sh b/infra/sandboxes/computer/start.sh index 72da15c..3a1a1b2 100755 --- a/infra/sandboxes/computer/start.sh +++ b/infra/sandboxes/computer/start.sh @@ -86,7 +86,7 @@ done if [[ "$browser_up" -ne 1 ]]; then echo "browser failed to start" >&2 cat /tmp/rakazo/browser.log >&2 || true - xterm -geometry 100x28+48+48 -bg "#111113" -fg "#E8E8EA" -cr "#E8E8EA" -title "Terminal" >/tmp/rakazo/xterm.log 2>&1 & + rakazo-terminal >/tmp/rakazo/xterm.log 2>&1 & fi x11vnc -display :1 -forever -shared -viewonly -nopw -listen 127.0.0.1 -rfbport 5900 -xkb -ncache 0 >/tmp/rakazo/x11vnc.log 2>&1 & diff --git a/infra/sandboxes/computer/terminal.sh b/infra/sandboxes/computer/terminal.sh new file mode 100644 index 0000000..a999a00 --- /dev/null +++ b/infra/sandboxes/computer/terminal.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec xterm \ + -u8 \ + -fa "DejaVu Sans Mono" \ + -fs 14 \ + -geometry 100x28+48+48 \ + -bg "#111113" \ + -fg "#E8E8EA" \ + -cr "#E8E8EA" \ + -title "Terminal" \ + "$@" diff --git a/infra/sandboxes/supervisor/src/computer-spec.test.ts b/infra/sandboxes/supervisor/src/computer-spec.test.ts index 03481e8..df60c25 100644 --- a/infra/sandboxes/supervisor/src/computer-spec.test.ts +++ b/infra/sandboxes/supervisor/src/computer-spec.test.ts @@ -127,6 +127,11 @@ describe("graphical computer spec", () => { expect(dockerfile).toMatch(/chromium/); expect(dockerfile).toMatch(/rakazo-browser\.desktop/); expect(dockerfile).toMatch(/control.py/); + expect(dockerfile).toMatch(/fonts-noto-cjk/); + expect(dockerfile).toMatch(/fonts-noto-color-emoji/); + expect(dockerfile).toMatch(/LANG=C\.UTF-8/); + expect(dockerfile).toMatch(/paste-text\.sh/); + expect(dockerfile).toMatch(/terminal\.sh/); expect(dockerfile).toMatch(/USER 1000:1000/); expect(start).toMatch(/rakazo-computer-control/); expect(start).toMatch(/rakazo-browser/); @@ -403,8 +408,10 @@ describe("graphical computer spec", () => { "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'mousemove', '--', '10', '20', 'click', '1'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'click', '--repeat', '3', '4'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'type', '--clearmodifiers', '--', 'hi'], ':1')", + "assert allow(['env', 'DISPLAY=:1', 'rakazo-paste', '繁體中文🙂'], ':1')", "assert allow(['env', 'DISPLAY=:2', 'xdg-open', 'https://example.com'], ':2')", "assert allow(['env', 'DISPLAY=:1', 'rakazo-browser'], ':1')", + "assert allow(['env', 'DISPLAY=:1', 'rakazo-terminal'], ':1')", "assert allow(['env', 'DISPLAY=:2', 'rakazo-browser', 'https://example.com'], ':2')", "assert allow(['env', 'DISPLAY=:1', 'xterm'], ':1')", "assert long_lived(['env', 'DISPLAY=:1', 'rakazo-browser'])", @@ -421,6 +428,7 @@ describe("graphical computer spec", () => { "assert not allow(['env', 'DISPLAY=:2', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')", "assert not allow(['env', 'DISPLAY=wayland-0', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')", "assert not allow(['env', 'DISPLAY=:1', 'xdg-open', 'a', 'b'], ':1')", + "assert not allow(['env', 'DISPLAY=:1', 'rakazo-paste', 'one', 'two'], ':1')", "known = set(module.KNOWN_LAUNCH)", "module.KNOWN_LAUNCH = frozenset({'sleep'})", "spawned = []", @@ -479,5 +487,9 @@ describe("graphical computer spec", () => { "click", "1", ]); + expect(xdotoolCommand({ kind: "clipboard", text: "繁體中文🙂" })).toEqual([ + "rakazo-paste", + "繁體中文🙂", + ]); }); }); diff --git a/infra/sandboxes/supervisor/src/computer-spec.ts b/infra/sandboxes/supervisor/src/computer-spec.ts index 95d0590..49edb77 100644 --- a/infra/sandboxes/supervisor/src/computer-spec.ts +++ b/infra/sandboxes/supervisor/src/computer-spec.ts @@ -88,6 +88,8 @@ export function containerCreateOptions(input: ComputerCreateInput) { "PATH=/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "NPM_CONFIG_PREFIX=/home/rakazo/.local", "PIP_USER=1", + "LANG=C.UTF-8", + "LC_ALL=C.UTF-8", ...(input.controlToken ? [`RAKAZO_COMPUTER_CONTROL_TOKEN=${input.controlToken}`] : []), ], Labels: { @@ -215,7 +217,7 @@ export function xdotoolCommand(input: SandboxInput): string[] { if (input.type === "up") return ["xdotool", "mouseup", btn]; return ["xdotool", "mousemove", "--", String(input.x), String(input.y), "click", btn]; } - return ["xdotool", "type", "--clearmodifiers", "--", input.text]; + return ["rakazo-paste", input.text]; } function mapKey(key: string) { diff --git a/infra/sandboxes/supervisor/src/home-ownership.test.ts b/infra/sandboxes/supervisor/src/home-ownership.test.ts index 4da6f2f..9d2072e 100644 --- a/infra/sandboxes/supervisor/src/home-ownership.test.ts +++ b/infra/sandboxes/supervisor/src/home-ownership.test.ts @@ -13,7 +13,11 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { assertComputerHomeWritable, assertOpenedDirectoryBeneathRoot } from "./home-ownership.js"; +import { + assertComputerHomeWritable, + assertOpenedDirectoryBeneathRoot, + computerHomeValidationIdentity, +} from "./home-ownership.js"; const roots: string[] = []; const DIRECTORY_OPEN_FLAGS = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; @@ -23,6 +27,17 @@ afterEach(async () => { }); describe("computer home ownership", () => { + it("uses the root supervisor identity for remapped desktop bind mounts", () => { + expect(computerHomeValidationIdentity(0, 0, 0, 0, 1000, 1000)).toEqual({ uid: 0, gid: 0 }); + }); + + it("keeps the computer identity for native uid-preserving bind mounts", () => { + expect(computerHomeValidationIdentity(0, 0, 1000, 1000, 1000, 1000)).toEqual({ + uid: 1000, + gid: 1000, + }); + }); + it("rejects a missing home instead of creating it as root", async () => { const parent = await mkdtemp(path.join(tmpdir(), "rakazo-home-missing-")); roots.push(parent); diff --git a/infra/sandboxes/supervisor/src/home-ownership.ts b/infra/sandboxes/supervisor/src/home-ownership.ts index b88e900..bee28fc 100644 --- a/infra/sandboxes/supervisor/src/home-ownership.ts +++ b/infra/sandboxes/supervisor/src/home-ownership.ts @@ -2,6 +2,24 @@ import { constants, type Stats } from "node:fs"; import { type FileHandle, lstat, open, opendir, readlink } from "node:fs/promises"; import path from "node:path"; +export function computerHomeValidationIdentity( + supervisorUid: number | undefined, + supervisorGid: number | undefined, + dataOwnerUid: number | undefined, + dataOwnerGid: number | undefined, + computerUid: number, + computerGid: number, +): { uid: number; gid: number } { + // Desktop VM bind mounts (including OrbStack) present host files as owned by + // the current container user. A root supervisor therefore sees uid 0 while + // the uid-1000 computer sees the same files as uid 1000. Validate the modes + // against the supervisor's mapped identity in that case. + if (supervisorUid === 0 && dataOwnerUid === 0) { + return { uid: supervisorUid, gid: dataOwnerGid ?? supervisorGid ?? 0 }; + } + return { uid: computerUid, gid: computerGid }; +} + const DIRECTORY_OPEN_FLAGS = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; function hasPermissions(stat: Stats, uid: number, gid: number, required: number): boolean { diff --git a/infra/sandboxes/supervisor/src/index.test.ts b/infra/sandboxes/supervisor/src/index.test.ts index a97c6e9..f31245b 100644 --- a/infra/sandboxes/supervisor/src/index.test.ts +++ b/infra/sandboxes/supervisor/src/index.test.ts @@ -230,6 +230,12 @@ describe("sandbox supervisor input containment", () => { }); }); + it("sends Unicode text through the sandbox clipboard helper", () => { + expect(containerActionStep({ kind: "clipboard", text: "中文輸入🙂" })).toEqual({ + argv: ["env", "DISPLAY=:1", "rakazo-paste", "中文輸入🙂"], + }); + }); + it("routes Docker browser aliases through the safe wrapper on every display", () => { for (const application of DOCKER_BROWSER_ALIASES) { expect( @@ -239,7 +245,7 @@ describe("sandbox supervisor input containment", () => { }); } expect(containerActionStep({ kind: "launch", application: "xterm" }, ":3")).toEqual({ - argv: ["env", "DISPLAY=:3", "xterm"], + argv: ["env", "DISPLAY=:3", "rakazo-terminal"], }); expect(containerActionStep({ kind: "open", path: "https://example.com" }, ":3")).toEqual({ argv: ["env", "DISPLAY=:3", "xdg-open", "https://example.com"], @@ -255,7 +261,7 @@ describe("sandbox supervisor input containment", () => { }); } expect(containerActionStep({ kind: "launch", application: "XTerm" }, ":3")).toEqual({ - argv: ["env", "DISPLAY=:3", "XTerm"], + argv: ["env", "DISPLAY=:3", "rakazo-terminal"], }); }); diff --git a/infra/sandboxes/supervisor/src/index.ts b/infra/sandboxes/supervisor/src/index.ts index 72e544e..3a52125 100644 --- a/infra/sandboxes/supervisor/src/index.ts +++ b/infra/sandboxes/supervisor/src/index.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; +import { lstat, mkdir } from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -29,7 +29,7 @@ import { screenUrlFor, xdotoolCommand, } from "./computer-spec.js"; -import { assertComputerHomeWritable } from "./home-ownership.js"; +import { assertComputerHomeWritable, computerHomeValidationIdentity } from "./home-ownership.js"; import { assertRequestIdentity, attemptComputerControl, @@ -156,14 +156,27 @@ app.post("/computers", async (c) => { // Before replacing or creating a container, validate its home without // privileged filesystem mutations that could escape via concurrent renames. // Match hostComputerUser(): missing/root host identity falls back to 1000:1000. - const effectiveUid = + let effectiveUid = runtimeInfo || hostUid === undefined || hostGid === undefined || hostUid === 0 ? COMPUTER_UID : hostUid; - const effectiveGid = + let effectiveGid = runtimeInfo || hostUid === undefined || hostGid === undefined || hostUid === 0 ? COMPUTER_GID : hostGid; + if (runtimeInfo) { + const dataStat = await lstat(dataDir); + const identity = computerHomeValidationIdentity( + hostUid, + hostGid, + dataStat.uid, + dataStat.gid, + effectiveUid, + effectiveGid, + ); + effectiveUid = identity.uid; + effectiveGid = identity.gid; + } await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid); if (existing) { await existing.remove({ force: true }).catch(() => undefined); @@ -621,6 +634,8 @@ async function ensureComputerImage() { "control.py", "xcapture.c", "rakazo-browser", + "paste-text.sh", + "terminal.sh", "rakazo-browser.desktop", "embed.html", "clipboard-bridge.js", diff --git a/infra/sandboxes/supervisor/src/supervisor-logic.ts b/infra/sandboxes/supervisor/src/supervisor-logic.ts index 805a4a2..6850b3c 100644 --- a/infra/sandboxes/supervisor/src/supervisor-logic.ts +++ b/infra/sandboxes/supervisor/src/supervisor-logic.ts @@ -40,6 +40,8 @@ export const DOCKER_BROWSER_ALIASES = new Set([ "rakazo-browser", ]); +export const DOCKER_TERMINAL_ALIASES = new Set(["terminal", "xterm", "rakazo-terminal"]); + export function assertRequestIdentity( botId: string | undefined, spaceId: string | undefined, @@ -320,9 +322,12 @@ export function containerActionStep( : workspaceTarget(normalizeWorkspaceRelative(action.path)); argv = ["env", `DISPLAY=${display}`, "xdg-open", target]; } else { - const application = DOCKER_BROWSER_ALIASES.has(action.application.toLowerCase()) + const normalizedApplication = action.application.toLowerCase(); + const application = DOCKER_BROWSER_ALIASES.has(normalizedApplication) ? "rakazo-browser" - : action.application; + : DOCKER_TERMINAL_ALIASES.has(normalizedApplication) + ? "rakazo-terminal" + : action.application; argv = ["env", `DISPLAY=${display}`, application, ...(action.uri ? [action.uri] : [])]; } return { argv }; diff --git a/packages/core/src/avatar-shape.ts b/packages/core/src/avatar-shape.ts index a2955ba..120b279 100644 --- a/packages/core/src/avatar-shape.ts +++ b/packages/core/src/avatar-shape.ts @@ -203,13 +203,13 @@ export function avatarShapePath(shape: AvatarShape): string { case "pill": return "M-48-26H48A26 26 0 0 1 48 26H-48A26 26 0 0 1-48-26Z"; case "triangle": - return "M0-52L52 46H-52Z"; + return "M0-52C4-52 7-49 10-43L51 36C56 46 50 52 39 50H-39C-50 52-56 46-51 36L-10-43C-7-49-4-52 0-52Z"; case "hexagon": - return "M-26-48L26-48L52 0L26 48L-26 48L-52 0Z"; + return "M-23-49C-30-49-34-45-38-38L-52-9C-55-3-55 3-52 9L-38 38C-34 45-30 49-23 49H23C30 49 34 45 38 38L52 9C55 3 55-3 52-9L38-38C34-45 30-49 23-49Z"; case "cloud": - return "M-38 20C-52 20-56 4-44-6C-50-24-28-36-16-26C-8-42 20-42 28-24C46-30 62-8 50 10C62 14 58 28 42 28H-38Z"; + return "M-37 39C-51 39-58 29-56 17C-55 8-49 1-40-3C-45-20-33-36-16-38C-8-51 14-54 27-41C34-34 38-26 38-18C52-16 60-5 59 8C68 16 63 33 50 37C45 40 40 41 34 40C25 48 10 50 0 44C-10 50-26 48-34 40C-35 40-36 40-37 39Z"; case "teardrop": - return "M0-50C28-50 50-26 50 2C50 24 22 42 8 54L0 62L-8 54C-22 42-50 24-50 2C-50-26-28-50 0-50Z"; + return "M0-52C28-52 50-28 50 0C50 22 23 39 9 50C4 54-4 54-9 50C-23 39-50 22-50 0C-50-28-28-52 0-52Z"; } } @@ -229,7 +229,7 @@ export function avatarShapeClipCss(shape: AvatarShape): string | undefined { case "hexagon": return "polygon(25% 6%, 75% 6%, 98% 50%, 75% 94%, 25% 94%, 2% 50%)"; case "cloud": - return "polygon(16% 68%, 8% 52%, 16% 38%, 28% 28%, 40% 22%, 54% 18%, 70% 24%, 82% 36%, 90% 50%, 84% 64%, 70% 72%, 28% 72%)"; + return "polygon(12% 82%, 4% 66%, 9% 48%, 20% 39%, 25% 23%, 40% 11%, 56% 16%, 67% 30%, 82% 31%, 95% 45%, 96% 63%, 87% 78%, 70% 84%, 54% 88%, 39% 86%, 27% 82%)"; case "teardrop": return "polygon(50% 4%, 90% 32%, 82% 78%, 50% 98%, 18% 78%, 10% 32%)"; } diff --git a/packages/ui-web/src/bot-avatar.test.tsx b/packages/ui-web/src/bot-avatar.test.tsx index d3632ec..8d4c041 100644 --- a/packages/ui-web/src/bot-avatar.test.tsx +++ b/packages/ui-web/src/bot-avatar.test.tsx @@ -86,6 +86,22 @@ describe("BotAvatar", () => { } }); + it("keeps cute working motion when a user selects a fixed shape", () => { + const html = renderToString( + , + ); + + expect(html).toContain('data-shape="cloud"'); + expect(html).toMatch(/data-motion-family="\d"/); + expect(html).toContain("rakazo-organic-avatar-body-working"); + }); + it("clips a selected robot shape onto the visor face", () => { const html = renderToString( , @@ -131,6 +147,7 @@ describe("BotAvatar", () => { ); expect(idle).toContain("rakazo-organic-avatar-eyes-idle"); expect(idle).toContain("rakazo-organic-avatar-eyes-working"); + expect(working).toContain('data-gaze="upper-left"'); expect(idle).toContain("rakazo-organic-avatar-body-idle"); expect(idle).toContain("rakazo-organic-avatar-body-working"); expect(readFileSync(new URL("./styles.css", import.meta.url), "utf8")).not.toMatch( diff --git a/packages/ui-web/src/bot-avatar.tsx b/packages/ui-web/src/bot-avatar.tsx index d461494..0bc0aa3 100644 --- a/packages/ui-web/src/bot-avatar.tsx +++ b/packages/ui-web/src/bot-avatar.tsx @@ -262,6 +262,7 @@ function OrganicAvatar({ data-working={isWorking} data-shape={shape ?? undefined} data-shape-family={shape ? undefined : seed % 10} + data-motion-family={seed % 10} data-eye-pattern={seed % 4} style={{ width: size, @@ -300,6 +301,7 @@ function OrganicAvatar({ diff --git a/packages/ui-web/src/styles.css b/packages/ui-web/src/styles.css index 0cdf55a..6877823 100644 --- a/packages/ui-web/src/styles.css +++ b/packages/ui-web/src/styles.css @@ -96,50 +96,39 @@ animation: rakazo-eyes-idle-3 6.8s ease-in-out -2.3s infinite; } -.rakazo-organic-avatar[data-eye-pattern="0"] .rakazo-organic-avatar-eyes-working { - animation: rakazo-organic-eyes-loop-0 6.6s ease-in-out infinite; +.rakazo-organic-avatar[data-eye-pattern] .rakazo-organic-avatar-eyes-working { + transform-origin: center; + animation: rakazo-organic-eyes-thinking 3.2s ease-in-out infinite; } -.rakazo-organic-avatar[data-eye-pattern="1"] .rakazo-organic-avatar-eyes-working { - animation: rakazo-organic-eyes-loop-1 7.2s ease-in-out -1.4s infinite; -} - -.rakazo-organic-avatar[data-eye-pattern="2"] .rakazo-organic-avatar-eyes-working { - animation: rakazo-organic-eyes-loop-2 6.1s ease-in-out -3.2s infinite; -} - -.rakazo-organic-avatar[data-eye-pattern="3"] .rakazo-organic-avatar-eyes-working { - animation: rakazo-organic-eyes-loop-3 7.8s ease-in-out -2.1s infinite; -} - -.rakazo-organic-avatar[data-shape-family="0"] .rakazo-organic-avatar-body-working { +.rakazo-organic-avatar[data-motion-family="0"] .rakazo-organic-avatar-body-working { animation: rakazo-organic-float 1.8s ease-in-out infinite; } -.rakazo-organic-avatar[data-shape-family="1"] .rakazo-organic-avatar-body-working { +.rakazo-organic-avatar[data-motion-family="1"] .rakazo-organic-avatar-body-working { animation: rakazo-organic-stretch 1.35s ease-in-out infinite; } -.rakazo-organic-avatar[data-shape-family="2"] .rakazo-organic-avatar-body-working, -.rakazo-organic-avatar[data-shape-family="8"] .rakazo-organic-avatar-body-working { +.rakazo-organic-avatar[data-motion-family="2"] .rakazo-organic-avatar-body-working, +.rakazo-organic-avatar[data-motion-family="8"] .rakazo-organic-avatar-body-working { animation: rakazo-organic-sway 1.6s ease-in-out infinite; } -.rakazo-organic-avatar[data-shape-family="3"] .rakazo-organic-avatar-body-working, -.rakazo-organic-avatar[data-shape-family="4"] .rakazo-organic-avatar-body-working { +.rakazo-organic-avatar[data-motion-family="3"] .rakazo-organic-avatar-body-working, +.rakazo-organic-avatar[data-motion-family="4"] .rakazo-organic-avatar-body-working { animation: rakazo-organic-turn 2.4s ease-in-out infinite; } -.rakazo-organic-avatar[data-shape-family="5"] .rakazo-organic-avatar-body-working, -.rakazo-organic-avatar[data-shape-family="9"] .rakazo-organic-avatar-body-working { +.rakazo-organic-avatar[data-motion-family="5"] .rakazo-organic-avatar-body-working, +.rakazo-organic-avatar[data-motion-family="9"] .rakazo-organic-avatar-body-working { animation: rakazo-organic-squash 1.35s ease-in-out infinite; } -.rakazo-organic-avatar[data-shape-family="6"] .rakazo-organic-avatar-body-working { +.rakazo-organic-avatar[data-motion-family="6"] .rakazo-organic-avatar-body-working { animation: rakazo-organic-pulse 1.1s ease-in-out infinite; } -.rakazo-organic-avatar[data-shape-family="7"] .rakazo-organic-avatar-body-working { +.rakazo-organic-avatar[data-motion-family="7"] .rakazo-organic-avatar-body-working { transform-origin: center 20%; animation: rakazo-organic-ring 1.35s ease-in-out infinite; } @@ -227,106 +216,24 @@ } } -@keyframes rakazo-organic-eyes-loop-0 { +@keyframes rakazo-organic-eyes-thinking { 0%, + 42%, 100% { - transform: translate(-9%, 1%) scaleY(0.94); + transform: translate(-12%, -11%) rotate(-2deg) scaleY(1); } - 18% { - transform: translate(9%, -2%) scaleY(1.04); + 52% { + transform: translate(-14%, -13%) rotate(-3deg) scale(1.04, 1.06); } - 34% { - transform: translate(0, -8%) scale(1.08); + 62%, + 80% { + transform: translate(-10%, -9%) rotate(-1deg) scaleY(1); } - 48% { - transform: scale(1.12, 0.82); - } - 51% { - transform: scale(1.05, 0.08); - } - 54% { - transform: scale(1.12, 0.9); - } - 72% { - transform: translate(-5%, 7%) scaleY(0.9); - } - 86% { - transform: translate(7%, 3%); - } -} - -@keyframes rakazo-organic-eyes-loop-1 { - 0%, - 100% { - transform: translate(0, -8%) scale(1.06); - } - 16% { - transform: translate(8%, 2%); - } - 31% { - transform: translate(0, 8%) scaleY(0.9); - } - 46% { - transform: translate(-8%, 2%); - } - 61% { - transform: scale(0.88, 1.1); - } - 76% { - transform: translateY(-7%) scaleY(1.08); + 84% { + transform: translate(-10%, -9%) rotate(-1deg) scaleY(0.08); } 88% { - transform: translateY(6%) scale(1.08, 0.86); - } -} - -@keyframes rakazo-organic-eyes-loop-2 { - 0%, - 100% { - transform: scale(0.9, 1.08); - } - 20% { - transform: scale(1.14, 0.84); - } - 24% { - transform: scale(1.08, 0.08); - } - 28% { - transform: scale(1.02); - } - 45% { - transform: translate(-9%, -3%) scaleY(0.94); - } - 62% { - transform: translate(9%, 2%) scaleY(1.05); - } - 80% { - transform: translate(0, -8%) scale(1.06); - } -} - -@keyframes rakazo-organic-eyes-loop-3 { - 0%, - 100% { - transform: translateY(7%) scaleY(0.9); - } - 14% { - transform: translateY(-9%) scaleY(1.08); - } - 30% { - transform: translate(8%, -2%); - } - 47% { - transform: translate(-8%, 3%) scaleY(0.94); - } - 63% { - transform: scale(1.12, 0.82); - } - 78% { - transform: translate(0, -7%) scale(0.9, 1.08); - } - 91% { - transform: translate(0, 5%) scale(1.06, 0.9); + transform: translate(-12%, -11%) rotate(-2deg) scaleY(1); } } @@ -443,20 +350,19 @@ } @keyframes rakazo-eyes-working { - 0% { - transform: translate(-4px, 0) scale(0.92, 0.95); - } - 25% { - transform: translate(0, -2px) scale(1.08, 1.08); - } - 50% { - transform: translate(4px, 0) scale(0.92, 0.95); - } - 75% { - transform: translate(0, 2px) scale(1, 0.9); - } + 0%, + 55%, 100% { - transform: translate(-4px, 0) scale(0.92, 0.95); + transform: translate(-3px, -2px) scale(1, 1.04); + } + 68% { + transform: translate(-4px, -3px) scale(1.04, 1.08); + } + 82% { + transform: translate(-3px, -2px) scaleY(0.08); + } + 86% { + transform: translate(-3px, -2px) scaleY(1.04); } } @@ -475,7 +381,7 @@ transition: none; } - .rakazo-organic-avatar[data-shape-family] .rakazo-organic-avatar-body { + .rakazo-organic-avatar[data-motion-family] .rakazo-organic-avatar-body { animation: none; d: var(--rakazo-organic-path); transition: none;