fix avator

This commit is contained in:
王性驊 2026-09-02 13:40:18 +08:00
parent 5674f1e8eb
commit afe8149988
27 changed files with 489 additions and 194 deletions

View File

@ -22,6 +22,17 @@ type BotSettingsRecord = MobileBot & {
description?: string;
};
const AVATAR_SHAPE_LABELS: Record<AvatarShape, string> = {
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<BotSettingsRecord>("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<BotSettingsRecord>("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"
/>
<Text
numberOfLines={1}
style={{
marginTop: 3,
color: selected ? "#ECECEE" : "#85858A",
fontSize: 9,
}}
>
{AVATAR_SHAPE_LABELS[shape]}
</Text>
</Pressable>
);
})}

View File

@ -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);
});

View File

@ -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");

View File

@ -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(
<BotAvatar
color="#D9508A"
identity="reduced-motion"
size={120}
status="running"
variant="organic"
/>,
<main
style={{
minHeight: "100vh",
display: "grid",
gridTemplateColumns: "repeat(4, 120px)",
alignItems: "center",
justifyContent: "center",
gap: 28,
background: "#101012",
}}
>
{AVATAR_SHAPES.map((shape) => (
<BotAvatar
key={shape}
color="#D9508A"
identity={`motion-${shape}`}
size={120}
shape={shape}
status="running"
variant="organic"
/>
))}
</main>,
);

View File

@ -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);
});

View File

@ -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<HTMLInputElement>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div data-testid="bot-avatar-editor" className="flex flex-col items-center">
<BotFace bot={bot} size={64} />
<div className="mt-4 grid grid-cols-4 gap-2">
<BotFace bot={preview} size={72} variant="organic" />
<fieldset className="mt-5 grid grid-cols-4 gap-2 border-0 p-0">
<legend className="sr-only">{t`Avatar shape`}</legend>
{AVATAR_SHAPES.map((shape) => {
const selected = (bot.avatarShape ?? "circle") === shape && !bot.hasAvatarImage;
const selected = (preview.avatarShape ?? "circle") === shape && !preview.hasAvatarImage;
return (
<button
key={shape}
@ -134,26 +152,33 @@ export function BotAvatarEditor({
aria-pressed={selected}
disabled={pending}
onClick={() => void apply({ avatarShape: shape, avatarImageArtifactId: null })}
className={`flex h-12 w-12 items-center justify-center rounded-[12px] border disabled:opacity-50 ${
className={`relative flex h-[68px] w-[60px] flex-col items-center justify-center gap-1 rounded-[14px] border text-[10.5px] transition-[border-color,background-color,transform] disabled:opacity-50 ${
selected
? "border-[#5A5A62] bg-[#1A1A1D]"
: "border-[#26262A] hover:border-[#3A3A40]"
? "border-[#777780] bg-[#202024] text-[#ECECEE]"
: "border-[#26262A] text-[#85858A] hover:border-[#4A4A52] hover:bg-[#17171A]"
}`}
>
<BotAvatar
color={bot.color}
identity={bot.id}
size={28}
color={preview.color}
identity={preview.id}
size={38}
shape={shape}
variant="robot"
variant="organic"
/>
<span>{shapeLabels[shape]}</span>
{selected ? (
<span
aria-hidden="true"
className="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full bg-[#ECECEE]"
/>
) : null}
</button>
);
})}
</div>
</fieldset>
<div className="mt-3 flex flex-wrap justify-center gap-2">
{BOT_COLORS.map((color) => {
const selected = bot.color.toLowerCase() === color.toLowerCase();
const selected = preview.color.toLowerCase() === color.toLowerCase();
return (
<button
key={color}
@ -195,7 +220,7 @@ export function BotAvatarEditor({
</button>
<button
type="button"
disabled={pending || (!bot.avatarShape && !bot.hasAvatarImage)}
disabled={pending || (!preview.avatarShape && !preview.hasAvatarImage)}
onClick={() => void apply({ avatarShape: null, avatarImageArtifactId: null })}
className="disabled:opacity-50"
>

View File

@ -19,7 +19,7 @@ describe("collaboration transcript markers", () => {
expect(html).toContain('class="flex justify-start"');
expect(html).toContain('class="inline-flex max-w-full');
expect(html).toContain('class="truncate"');
expect(html).toContain("rakazo-bot-avatar");
expect(html).toContain("rakazo-organic-avatar");
expect(html).toContain("Message from Research");
expect(html).not.toContain("{peer}");
});
@ -33,7 +33,10 @@ describe("collaboration transcript markers", () => {
);
expect(html).toContain('role="status"');
expect(html).toContain('data-testid="active-bot-glyph"');
expect(html).toContain('data-working="true"');
expect(html).toContain("rakazo-bot-avatar-ring");
expect(html).toContain("rakazo-organic-avatar-body-working");
expect(html).toContain("Research is working");
expect(html).toContain("bui-thought-spark");
});
});

View File

@ -1,5 +1,5 @@
import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web";
import { LoadingState } from "./primitives";
import { Shimmer } from "./primitives";
/** Lightweight peer event shown without exposing the exchanged message body. */
export function CollaborationMarker({
@ -35,8 +35,22 @@ export function CollaborationMarker({
export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; label: string }) {
return (
<div className="flex min-h-10 items-center px-1">
<LoadingState indicator={<GroupAvatar members={bots} size={28} />} label={label} />
<div
role="status"
data-testid="active-bot-glyph"
className="flex min-h-12 items-center gap-2.5 px-1"
>
<span className="bui-thinking-avatar relative grid h-10 w-10 shrink-0 place-items-center">
<GroupAvatar members={bots} size={30} />
<span aria-hidden="true" className="bui-thought-dot bui-thought-dot-one" />
<span aria-hidden="true" className="bui-thought-dot bui-thought-dot-two" />
<span aria-hidden="true" className="bui-thought-spark">
</span>
</span>
<span className="text-[13px] font-medium">
<Shimmer>{label}</Shimmer>
</span>
</div>
);
}

View File

@ -59,3 +59,83 @@
transform: translateY(0);
}
}
.bui-thinking-avatar {
animation: bui-thinking-bob 1.8s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
.bui-thought-dot {
position: absolute;
border-radius: 999px;
background: var(--bui-ink-2);
box-shadow: 0 0 8px color-mix(in srgb, var(--bui-accent) 55%, transparent);
opacity: 0.55;
}
.bui-thought-dot-one {
top: 2px;
right: 3px;
width: 4px;
height: 4px;
animation: bui-thought-pop 1.8s ease-in-out -0.3s infinite;
}
.bui-thought-dot-two {
top: -3px;
right: -2px;
width: 6px;
height: 6px;
animation: bui-thought-pop 1.8s ease-in-out -0.7s infinite;
}
.bui-thought-spark {
position: absolute;
top: -13px;
right: -10px;
color: var(--bui-ink);
font-size: 12px;
line-height: 1;
animation: bui-thought-spark 1.8s ease-in-out infinite;
}
@keyframes bui-thinking-bob {
0%,
100% {
transform: translateY(1px) rotate(-1deg);
}
50% {
transform: translateY(-2px) rotate(1deg);
}
}
@keyframes bui-thought-pop {
0%,
100% {
opacity: 0.25;
transform: translate(-2px, 3px) scale(0.65);
}
50% {
opacity: 0.9;
transform: translate(1px, -1px) scale(1);
}
}
@keyframes bui-thought-spark {
0%,
100% {
opacity: 0.35;
transform: rotate(-12deg) scale(0.72);
}
50% {
opacity: 1;
transform: rotate(10deg) scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.bui-thinking-avatar,
.bui-thought-dot,
.bui-thought-spark {
animation: none;
}
}

View File

@ -6,26 +6,35 @@ import { WindowChrome } from "./WindowChrome";
export function WelcomePage() {
const navigate = useNavigate();
return (
<div className="flex min-h-full flex-col bg-[#08080A]">
<div className="app-drag flex gap-2 px-5 py-[18px]">
<div className="flex min-h-full min-h-[100svh] flex-col overflow-hidden bg-[#08080A]">
<div className="app-drag flex shrink-0 gap-2 px-5 py-[18px]">
<WindowChrome />
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-11 pb-[90px]">
<div className="flex items-center gap-[26px]">
<div className="flex h-[88px] w-[88px] items-center justify-center gap-[13px] rounded-full bg-[#F2F2F0]">
<span className="h-6 w-[11px] rounded-full bg-[#101012]" />
<span className="h-6 w-[11px] rounded-full bg-[#101012]" />
<main className="flex min-h-0 flex-1 flex-col items-center justify-center gap-[clamp(1.75rem,5vh,2.75rem)] px-5 pt-4 pb-[clamp(2rem,10vh,5.625rem)]">
<div
className="flex max-w-full items-center justify-center gap-[clamp(0.875rem,2vw,1.625rem)] max-[360px]:flex-col"
data-testid="welcome-brand"
>
<div
className="flex aspect-square h-[clamp(4.5rem,9vw,6.25rem)] shrink-0 items-center justify-center gap-[clamp(0.625rem,1.2vw,0.8125rem)] rounded-full bg-[#F2F2F0]"
data-testid="welcome-mark"
>
<span className="h-[clamp(1.125rem,2.5vw,1.5rem)] w-[clamp(0.5rem,1.1vw,0.6875rem)] rounded-full bg-[#101012]" />
<span className="h-[clamp(1.125rem,2.5vw,1.5rem)] w-[clamp(0.5rem,1.1vw,0.6875rem)] rounded-full bg-[#101012]" />
</div>
<div className="text-center">
<div className="text-[64px] leading-none tracking-[-0.03em] text-white">
<div className="min-w-0 text-center" data-testid="welcome-wordmark">
<div
className="text-[clamp(2.25rem,5.5vw,3.75rem)] leading-[0.95] tracking-[-0.03em] whitespace-nowrap text-white"
data-testid="welcome-title"
>
{PRODUCT_NAME}
</div>
<div className="mt-3 text-[22px] tracking-[0.08em] text-[#A8A8AD]">
<div className="mt-[clamp(0.5rem,1.5vw,0.75rem)] text-[clamp(1rem,2vw,1.375rem)] leading-tight tracking-[0.08em] text-[#A8A8AD]">
{PRODUCT_TAGLINE}
</div>
</div>
</div>
<p className="max-w-[600px] text-center text-[27px] leading-[1.4] text-[#E4E4E6]">
<p className="m-0 max-w-[600px] text-center text-[clamp(1.25rem,2.6vw,1.6875rem)] leading-[1.4] text-[#E4E4E6]">
<Trans>
Your team of always-on agents
<br />
@ -35,11 +44,11 @@ export function WelcomePage() {
<button
type="button"
onClick={() => navigate("/sign-in")}
className="app-no-drag rounded-full bg-[#1B1B1F] px-[34px] py-[15px] text-[19px] text-[#F2F2F3] transition hover:scale-[1.04] hover:bg-[#26262B]"
className="app-no-drag min-h-12 rounded-full bg-[#1B1B1F] px-[34px] py-3 text-[18px] text-[#F2F2F3] transition hover:scale-[1.04] hover:bg-[#26262B]"
>
<Trans>Sign in&nbsp;&nbsp;</Trans>
</button>
</div>
</main>
</div>
);
}

View File

@ -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

View File

@ -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":

View File

@ -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]

View File

@ -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"

View File

@ -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 &

View File

@ -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" \
"$@"

View File

@ -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",
"繁體中文🙂",
]);
});
});

View File

@ -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) {

View File

@ -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);

View File

@ -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 {

View File

@ -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"],
});
});

View File

@ -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",

View File

@ -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 };

View File

@ -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%)";
}

View File

@ -86,6 +86,22 @@ describe("BotAvatar", () => {
}
});
it("keeps cute working motion when a user selects a fixed shape", () => {
const html = renderToString(
<BotAvatar
color="#3EC5A8"
identity="cloud-bot"
status="running"
shape="cloud"
variant="organic"
/>,
);
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(
<BotAvatar color="#3EC5A8" identity="chief" shape="hexagon" variant="robot" />,
@ -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(

View File

@ -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({
<g
key={mode}
className={`rakazo-organic-avatar-eyes rakazo-organic-avatar-eyes-${mode}`}
data-gaze={mode === "working" ? "upper-left" : undefined}
fill="#101014"
>
<rect x="-14" y="-12" width="7" height="24" rx="3.5" />

View File

@ -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;