Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"dev:privileged": "next dev --port 80 --hostname 0.0.0.0",
"prebuild": "rm -rf .next/standalone/.next/cache/images 2>/dev/null || true",
"build": "next build",
"postbuild": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public 2>/dev/null || true",
"postbuild": "SRVJS=$(find .next/standalone -maxdepth 3 -name server.js -print -quit); [ -n \"$SRVJS\" ] && SDIR=$(dirname \"$SRVJS\") && cp -r .next/static \"$SDIR/.next/static\" && cp -r public \"$SDIR/public\" && ln -sf \"$(pwd)/$SRVJS\" .next/standalone/server.js",
"start": "PORT=80 HOSTNAME=0.0.0.0 bun run production-server.js",
"lint": "eslint",
"test": "vitest run",
Expand Down
26 changes: 22 additions & 4 deletions scripts/start-vnc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,24 @@ find_xauth() {
}

XAUTH=$(find_xauth 2>/dev/null)
HAS_MONITOR=false
if [ -n "$XAUTH" ] && XAUTHORITY="$XAUTH" xdpyinfo -display :0 >/dev/null 2>&1; then
echo "[vnc] Display :0 found — mirroring"
# Check if a physical monitor is actually connected
if XAUTHORITY="$XAUTH" xrandr -display :0 --query 2>/dev/null | grep -q " connected"; then
HAS_MONITOR=true
fi
fi

if [ "$HAS_MONITOR" = true ]; then
echo "[vnc] Physical display :0 found — mirroring"
XAUTHORITY="$XAUTH" xset -display :0 s 0 0 2>/dev/null
XAUTHORITY="$XAUTH" xset -display :0 s noblank 2>/dev/null
# -noxdamage required: GNOME's Mutter compositor breaks X DAMAGE updates
exec x11vnc -display :0 -auth "$XAUTH" -rfbport "$VNC_PORT" -forever -shared -nopw -localhost -noxdamage
fi

echo "[vnc] No physical monitor — using virtual desktop"

# ─── Virtual desktop ─────────────────────────────────────────────────────────

VDISPLAY=99
Expand All @@ -40,11 +53,16 @@ export DISPLAY=":${VDISPLAY}"
export DBUS_SESSION_BUS_ADDRESS=""

# Minimal WM — just enough to render and manage app windows
if command -v openbox &>/dev/null; then
if command -v openbox &>/dev/null && ! pgrep -x openbox &>/dev/null; then
openbox &
elif command -v xterm &>/dev/null; then
elif command -v xterm &>/dev/null && ! pgrep -x xterm &>/dev/null; then
xterm -geometry 100x30+0+0 &
fi
sleep 1

exec x11vnc -display ":${VDISPLAY}" -rfbport "${VNC_PORT}" -forever -shared -nopw -localhost -noxdamage
# Taskbar panel so minimized windows can be restored
if command -v tint2 &>/dev/null && ! pgrep -x tint2 &>/dev/null; then
tint2 &
fi

exec x11vnc -display ":${VDISPLAY}" -rfbport "${VNC_PORT}" -forever -shared -nopw -localhost
26 changes: 25 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,13 @@ export default function ChromeDesktop() {
const [chatOpen, setChatOpen] = useState(false);
const [mascotX, setMascotX] = useState(85);

// Open chat when a skill is installed/uninstalled/toggled
useEffect(() => {
const handler = () => setChatOpen(true);
window.addEventListener('clawbox-skill-installed', handler);
return () => window.removeEventListener('clawbox-skill-installed', handler);
}, []);

// ─── Mascot visibility ───
const [mascotHidden, setMascotHidden] = useState(false);
useEffect(() => {
Expand Down Expand Up @@ -741,9 +748,23 @@ export default function ChromeDesktop() {
setUninstallConfirm(appId);
}, []);

const confirmUninstallApp = useCallback(() => {
const confirmUninstallApp = useCallback(async () => {
if (!uninstallConfirm) return;
const appId = uninstallConfirm;
// Remove skill files and reload gateway
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
await fetch("/setup-api/apps/uninstall", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ appId }),
signal: controller.signal,
});
clearTimeout(timer);
} catch (err) {
console.warn("[uninstall] Failed to uninstall skill:", err);
}
setInstalledApps((prev) => prev.filter((id) => id !== appId));
setOpenWindows((prev) => prev.filter((w) => w.appId !== `installed-${appId}`));
setIconPositions((prev) => {
Expand All @@ -752,6 +773,8 @@ export default function ChromeDesktop() {
return next;
});
setUninstallConfirm(null);
// Refresh agent session with updated skills
window.dispatchEvent(new CustomEvent('clawbox-skill-installed', { detail: { action: 'uninstall', id: appId } }));
}, [uninstallConfirm]);

// Get all apps including installed ones
Expand Down Expand Up @@ -994,6 +1017,7 @@ export default function ChromeDesktop() {
appId={app.storeApp.id}
storeApp={app.storeApp}
icon={<InstalledAppIcon appId={app.storeApp.id} iconUrl={app.storeApp.iconUrl} name={app.storeApp.name} size="w-12 h-12" />}
onUninstall={requestUninstallApp}
/>
) : null;
case "files":
Expand Down
22 changes: 8 additions & 14 deletions src/app/setup-api/apps/install/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
import { DATA_DIR, CONFIG_ROOT } from "@/lib/config-store";
import { reloadGateway, getSkillsDir } from "@/lib/openclaw-config";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -36,24 +37,12 @@ function findClawhub(): string {
return "clawhub"; // fallback to PATH
}

// Skills directory — same as OpenClaw workspace
function getSkillsDir(): string {
const home = process.env.HOME || "/home/clawbox";
// Check OpenClaw workspace config
const openclawConfig = path.join(home, ".openclaw", "openclaw.json");
try {
const config = JSON.parse(require("fs").readFileSync(openclawConfig, "utf-8"));
const workspace = config?.agents?.defaults?.workspace;
if (workspace) return workspace;
} catch {}
return path.join(home, "clawd");
}

export async function POST(req: Request) {
try {
const { appId } = await req.json();
if (!appId || typeof appId !== "string") {
return NextResponse.json({ error: "appId is required" }, { status: 400 });
if (!appId || typeof appId !== "string" || !/^[A-Za-z0-9_-]+$/.test(appId)) {
return NextResponse.json({ error: "Invalid appId" }, { status: 400 });
}

// Ensure icons directory exists
Expand Down Expand Up @@ -98,6 +87,11 @@ export async function POST(req: Request) {
clawhubResult = { success: false, error: msg };
}

// Reload the OpenClaw gateway so it picks up the new skill
if (clawhubResult.success) {
await reloadGateway();
}

return NextResponse.json({
ok: true,
appId,
Expand Down
70 changes: 70 additions & 0 deletions src/app/setup-api/apps/settings/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { execFile } from "child_process";
import { promisify } from "util";
import fs from "fs/promises";
import path from "path";

export const dynamic = "force-dynamic";

const execFileAsync = promisify(execFile);
const HOME = process.env.HOME || "/home/clawbox";
const OPENCLAW_BIN = path.join(HOME, ".npm-global", "bin", "openclaw");

/**
* Maps app settings from the UI to the config files that skills actually read.
*/
const CONFIG_WRITERS: Record<string, (settings: Record<string, string | boolean>) => Promise<void>> = {
"home-assistant": async (settings) => {
const configDir = path.join(HOME, ".config", "home-assistant");
const configFile = path.join(configDir, "config.json");
await fs.mkdir(configDir, { recursive: true });
const config: Record<string, unknown> = {};
if (settings.ha_url) config.url = settings.ha_url;
if (settings.ha_token) config.token = settings.ha_token;
await fs.writeFile(configFile, JSON.stringify(config, null, 2), { mode: 0o600 });
},
};

export async function POST(req: Request) {
try {
const { appId, settings } = await req.json();
if (!appId || typeof appId !== "string" || !/^[A-Za-z0-9_-]+$/.test(appId)) {
return NextResponse.json({ error: "Invalid appId" }, { status: 400 });
}
if (!settings || typeof settings !== "object") {
return NextResponse.json({ error: "settings is required" }, { status: 400 });
}

// Handle enable/disable via openclaw config
if ("_setEnabled" in settings) {
const enabled = !!settings._setEnabled;
try {
await execFileAsync(OPENCLAW_BIN, [
"config", "set",
`skills.entries.${appId}.enabled`,
enabled ? "true" : "false",
"--strict-json",
], {
timeout: 10_000,
env: { ...process.env, PATH: `${path.dirname(OPENCLAW_BIN)}:${process.env.PATH}` },
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: `Failed to toggle skill: ${msg}` }, { status: 500 });
}
return NextResponse.json({ ok: true, enabled });
}

// Write config file for the skill
const writer = CONFIG_WRITERS[appId];
if (writer) {
await writer(settings as Record<string, string | boolean>);
return NextResponse.json({ ok: true, configWritten: true });
}

return NextResponse.json({ ok: true, configWritten: false });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
67 changes: 67 additions & 0 deletions src/app/setup-api/apps/skill-info/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { execFile } from "child_process";
import { promisify } from "util";
import path from "path";

export const dynamic = "force-dynamic";

const execFileAsync = promisify(execFile);
const HOME = process.env.HOME || "/home/clawbox";
const OPENCLAW_BIN = path.join(HOME, ".npm-global", "bin", "openclaw");

interface SkillInfo {
name: string;
description: string;
emoji: string | null;
eligible: boolean;
primaryEnv: string | null;
requiredEnv: string[];
requiredBins: string[];
requiredConfig: string[];
source: string;
}

let cachedSkills: SkillInfo[] | null = null;
let cacheTime = 0;
const CACHE_TTL = 30_000;

async function loadSkills(): Promise<SkillInfo[]> {
if (cachedSkills && Date.now() - cacheTime < CACHE_TTL) return cachedSkills;
try {
const { stdout } = await execFileAsync(OPENCLAW_BIN, ["skills", "list", "--json"], {
timeout: 15_000,
env: { ...process.env, PATH: `${path.dirname(OPENCLAW_BIN)}:${process.env.PATH}` },
});
const data = JSON.parse(stdout);
const skills = (data.skills || []) as Record<string, unknown>[];
cachedSkills = skills.map((s) => ({
name: (s.name as string) || "",
description: (s.description as string) || "",
emoji: (s.emoji as string) || null,
eligible: !!(s.eligible),
primaryEnv: (s.primaryEnv as string) || null,
requiredEnv: ((s.missing as Record<string, unknown>)?.env as string[]) || [],
requiredBins: ((s.missing as Record<string, unknown>)?.bins as string[]) || [],
requiredConfig: ((s.missing as Record<string, unknown>)?.config as string[]) || [],
source: (s.source as string) || "",
}));
cacheTime = Date.now();
return cachedSkills;
} catch (err) {
console.warn("[skill-info] Failed to load skills:", err instanceof Error ? err.message : err);
return cachedSkills || [];
}
}

export async function GET(request: NextRequest) {
const appId = request.nextUrl.searchParams.get("appId");
const skills = await loadSkills();

if (appId) {
const skill = skills.find((s) => s.name === appId);
if (!skill) return NextResponse.json({ error: "Skill not found" }, { status: 404 });
return NextResponse.json(skill);
}

return NextResponse.json(skills);
}
42 changes: 42 additions & 0 deletions src/app/setup-api/apps/uninstall/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { reloadGateway, getSkillsDir } from "@/lib/openclaw-config";

export const dynamic = "force-dynamic";

const HOME = process.env.HOME || "/home/clawbox";

export async function POST(req: Request) {
try {
const { appId } = await req.json();
if (!appId || typeof appId !== "string" || !/^[A-Za-z0-9_-]+$/.test(appId)) {
return NextResponse.json({ error: "Invalid appId" }, { status: 400 });
}

// Remove the skill directory (with path traversal guard)
const skillRoot = path.resolve(getSkillsDir(), "skills");
const skillDir = path.resolve(skillRoot, appId);
if (!skillDir.startsWith(skillRoot + path.sep)) {
return NextResponse.json({ error: "Invalid appId" }, { status: 400 });
}
try {
await fs.rm(skillDir, { recursive: true, force: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: `Failed to remove skill: ${msg}` }, { status: 500 });
}

// Remove cached icon
const iconPath = path.join(HOME, "clawbox", "data", "icons", `${appId}.png`);
await fs.rm(iconPath, { force: true }).catch(() => {});

// Reload gateway so agent drops the skill
await reloadGateway();

return NextResponse.json({ ok: true, appId });
} catch (err) {
console.error("[uninstall] Uninstall failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "Uninstall failed" }, { status: 500 });
}
}
Loading
Loading