feat: installed apps, stop goal, auth fixes, remote commands

- Android: fetch installed apps via PackageManager, send to server on connect
- Android: add QUERY_ALL_PACKAGES permission for full app visibility
- Android: fix duplicate Intent import, increase accessibility retry window
- Android: default server URL to ws:// instead of wss://
- Server: store installed apps in device metadata JSONB
- Server: inject installed apps context into LLM prompt
- Server: preprocessor resolves app names from device's actual installed apps
- Server: add POST /goals/stop endpoint with AbortController cancellation
- Server: rewrite session middleware to direct DB token lookup
- Server: goals route fetches user's saved LLM config from DB
- Web: show installed apps in device detail Overview tab with search
- Web: add Stop button for running goals
- Web: replace API routes with remote commands (submitGoal, stopGoal)
- Web: add error display for goal submission failures
- Shared: add InstalledApp type and apps message to protocol
This commit is contained in:
Sanju Sivalingam
2026-02-17 22:50:18 +05:30
parent fae5fd3534
commit e300f04e13
17 changed files with 410 additions and 88 deletions

View File

@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application <application
android:name=".DroidClawApp" android:name=".DroidClawApp"

View File

@@ -41,12 +41,21 @@ class DroidClawAccessibilityService : AccessibilityService() {
} }
fun getScreenTree(): List<UIElement> { fun getScreenTree(): List<UIElement> {
val delays = longArrayOf(50, 100, 200) // Retry with increasing delays — apps like Contacts on Vivo
// can take 500ms+ to render after a cold launch
val delays = longArrayOf(50, 100, 200, 300, 500)
for (delayMs in delays) { for (delayMs in delays) {
val root = rootInActiveWindow val root = rootInActiveWindow
if (root != null) { if (root != null) {
try { try {
val elements = ScreenTreeBuilder.capture(root) val elements = ScreenTreeBuilder.capture(root)
// If we got a root but zero elements, the app may still be loading.
// Retry unless this is the last attempt.
if (elements.isEmpty() && delayMs < delays.last()) {
root.recycle()
runBlocking { delay(delayMs) }
continue
}
lastScreenTree.value = elements lastScreenTree.value = elements
return elements return elements
} finally { } finally {
@@ -55,7 +64,7 @@ class DroidClawAccessibilityService : AccessibilityService() {
} }
runBlocking { delay(delayMs) } runBlocking { delay(delayMs) }
} }
Log.w(TAG, "rootInActiveWindow null after retries") Log.w(TAG, "rootInActiveWindow null or empty after retries")
return emptyList() return emptyList()
} }

View File

@@ -22,7 +22,10 @@ import com.thisux.droidclaw.model.GoalMessage
import com.thisux.droidclaw.model.GoalStatus import com.thisux.droidclaw.model.GoalStatus
import com.thisux.droidclaw.model.AgentStep import com.thisux.droidclaw.model.AgentStep
import com.thisux.droidclaw.model.HeartbeatMessage import com.thisux.droidclaw.model.HeartbeatMessage
import com.thisux.droidclaw.model.AppsMessage
import com.thisux.droidclaw.model.InstalledAppInfo
import com.thisux.droidclaw.util.DeviceInfoHelper import com.thisux.droidclaw.util.DeviceInfoHelper
import android.content.pm.PackageManager
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -125,6 +128,12 @@ class ConnectionService : LifecycleService() {
ConnectionState.Disconnected -> "Disconnected" ConnectionState.Disconnected -> "Disconnected"
} }
) )
// Send installed apps list once connected
if (state == ConnectionState.Connected) {
val apps = getInstalledApps()
webSocket?.sendTyped(AppsMessage(apps = apps))
Log.i(TAG, "Sent ${apps.size} installed apps to server")
}
} }
} }
launch { ws.errorMessage.collect { errorMessage.value = it } } launch { ws.errorMessage.collect { errorMessage.value = it } }
@@ -179,6 +188,17 @@ class ConnectionService : LifecycleService() {
return null return null
} }
private fun getInstalledApps(): List<InstalledAppInfo> {
val pm = packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val activities = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL)
return activities.mapNotNull { resolveInfo ->
val pkg = resolveInfo.activityInfo.packageName
val label = resolveInfo.loadLabel(pm).toString()
InstalledAppInfo(packageName = pkg, label = label)
}.distinctBy { it.packageName }.sortedBy { it.label.lowercase() }
}
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(

View File

@@ -26,7 +26,7 @@ class SettingsStore(private val context: Context) {
} }
val serverUrl: Flow<String> = context.dataStore.data.map { prefs -> val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
prefs[SettingsKeys.SERVER_URL] ?: "wss://localhost:8080" prefs[SettingsKeys.SERVER_URL] ?: "ws://localhost:8080"
} }
val deviceName: Flow<String> = context.dataStore.data.map { prefs -> val deviceName: Flow<String> = context.dataStore.data.map { prefs ->

View File

@@ -58,6 +58,18 @@ data class HeartbeatMessage(
val isCharging: Boolean val isCharging: Boolean
) )
@Serializable
data class InstalledAppInfo(
val packageName: String,
val label: String
)
@Serializable
data class AppsMessage(
val type: String = "apps",
val apps: List<InstalledAppInfo>
)
@Serializable @Serializable
data class ServerMessage( data class ServerMessage(
val type: String, val type: String,

View File

@@ -51,7 +51,7 @@ fun SettingsScreen() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val apiKey by app.settingsStore.apiKey.collectAsState(initial = "") val apiKey by app.settingsStore.apiKey.collectAsState(initial = "")
val serverUrl by app.settingsStore.serverUrl.collectAsState(initial = "wss://localhost:8080") val serverUrl by app.settingsStore.serverUrl.collectAsState(initial = "ws://localhost:8080")
var editingApiKey by remember(apiKey) { mutableStateOf(apiKey) } var editingApiKey by remember(apiKey) { mutableStateOf(apiKey) }
var editingServerUrl by remember(serverUrl) { mutableStateOf(serverUrl) } var editingServerUrl by remember(serverUrl) { mutableStateOf(serverUrl) }

View File

@@ -1,4 +1,4 @@
import type { UIElement, DeviceInfo } from "./types.js"; import type { UIElement, DeviceInfo, InstalledApp } from "./types.js";
export type DeviceMessage = export type DeviceMessage =
| { type: "auth"; apiKey: string; deviceInfo?: DeviceInfo } | { type: "auth"; apiKey: string; deviceInfo?: DeviceInfo }
@@ -6,7 +6,8 @@ export type DeviceMessage =
| { type: "result"; requestId: string; success: boolean; error?: string; data?: string } | { type: "result"; requestId: string; success: boolean; error?: string; data?: string }
| { type: "goal"; text: string } | { type: "goal"; text: string }
| { type: "pong" } | { type: "pong" }
| { type: "heartbeat"; batteryLevel: number; isCharging: boolean }; | { type: "heartbeat"; batteryLevel: number; isCharging: boolean }
| { type: "apps"; apps: InstalledApp[] };
export type ServerToDeviceMessage = export type ServerToDeviceMessage =
| { type: "auth_ok"; deviceId: string } | { type: "auth_ok"; deviceId: string }

View File

@@ -61,6 +61,11 @@ export interface DeviceInfo {
isCharging: boolean; isCharging: boolean;
} }
export interface InstalledApp {
packageName: string;
label: string;
}
export interface ScreenState { export interface ScreenState {
elements: UIElement[]; elements: UIElement[];
screenshot?: string; screenshot?: string;

View File

@@ -26,7 +26,7 @@ import {
} from "./llm.js"; } from "./llm.js";
import { createStuckDetector } from "./stuck.js"; import { createStuckDetector } from "./stuck.js";
import { db } from "../db.js"; import { db } from "../db.js";
import { agentSession, agentStep } from "../schema.js"; import { agentSession, agentStep, device as deviceTable } from "../schema.js";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { UIElement, ActionDecision } from "@droidclaw/shared"; import type { UIElement, ActionDecision } from "@droidclaw/shared";
@@ -42,6 +42,8 @@ export interface AgentLoopOptions {
originalGoal?: string; originalGoal?: string;
llmConfig: LLMConfig; llmConfig: LLMConfig;
maxSteps?: number; maxSteps?: number;
/** Abort signal for cancellation */
signal?: AbortSignal;
onStep?: (step: AgentStep) => void; onStep?: (step: AgentStep) => void;
onComplete?: (result: AgentResult) => void; onComplete?: (result: AgentResult) => void;
} }
@@ -224,6 +226,7 @@ export async function runAgentLoop(
originalGoal, originalGoal,
llmConfig, llmConfig,
maxSteps = 30, maxSteps = 30,
signal,
onStep, onStep,
onComplete, onComplete,
} = options; } = options;
@@ -239,6 +242,28 @@ export async function runAgentLoop(
const recentActions: string[] = []; const recentActions: string[] = [];
let lastActionFeedback = ""; let lastActionFeedback = "";
// Fetch installed apps from device metadata for LLM context
let installedAppsContext = "";
if (persistentDeviceId) {
try {
const rows = await db
.select({ info: deviceTable.deviceInfo })
.from(deviceTable)
.where(eq(deviceTable.id, persistentDeviceId))
.limit(1);
const info = rows[0]?.info as Record<string, unknown> | null;
const apps = info?.installedApps as Array<{ packageName: string; label: string }> | undefined;
if (apps && apps.length > 0) {
installedAppsContext =
`\nINSTALLED_APPS (use exact packageName for "launch" action):\n` +
apps.map((a) => ` ${a.label}: ${a.packageName}`).join("\n") +
"\n";
}
} catch {
// Non-critical — continue without apps context
}
}
// Persist session to DB // Persist session to DB
if (persistentDeviceId) { if (persistentDeviceId) {
try { try {
@@ -268,6 +293,12 @@ export async function runAgentLoop(
try { try {
for (let step = 0; step < maxSteps; step++) { for (let step = 0; step < maxSteps; step++) {
// Check for cancellation
if (signal?.aborted) {
console.log(`[Agent ${sessionId}] Stopped by user at step ${step + 1}`);
break;
}
stepsUsed = step + 1; stepsUsed = step + 1;
// ── 1. Get screen state from device ───────────────────── // ── 1. Get screen state from device ─────────────────────
@@ -371,6 +402,7 @@ export async function runAgentLoop(
let userPrompt = let userPrompt =
`GOAL: ${goal}\n\n` + `GOAL: ${goal}\n\n` +
`STEP: ${step + 1}/${maxSteps}\n\n` + `STEP: ${step + 1}/${maxSteps}\n\n` +
installedAppsContext +
foregroundLine + foregroundLine +
actionFeedbackLine + actionFeedbackLine +
`SCREEN_CONTEXT:\n${JSON.stringify(elements, null, 2)}` + `SCREEN_CONTEXT:\n${JSON.stringify(elements, null, 2)}` +

View File

@@ -8,10 +8,21 @@
*/ */
import { sessions } from "../ws/sessions.js"; import { sessions } from "../ws/sessions.js";
import { db } from "../db.js";
import { device as deviceTable } from "../schema.js";
import { eq } from "drizzle-orm";
// ─── App Name → Package Name Map ──────────────────────────── // ─── Installed App type ──────────────────────────────────────
const APP_PACKAGES: Record<string, string> = { interface InstalledApp {
packageName: string;
label: string;
}
// ─── Fallback App Name → Package Name Map ────────────────────
// Used only when device has no installed apps data in DB.
const FALLBACK_PACKAGES: Record<string, string> = {
youtube: "com.google.android.youtube", youtube: "com.google.android.youtube",
gmail: "com.google.android.gm", gmail: "com.google.android.gm",
chrome: "com.android.chrome", chrome: "com.android.chrome",
@@ -79,12 +90,32 @@ interface PreprocessResult {
} }
/** /**
* Try to find a known app name at the start of a goal string. * Build a label→packageName lookup from the device's installed apps.
* Keys are lowercase app labels (e.g. "youtube", "play store").
*/
function buildInstalledAppMap(apps: InstalledApp[]): Record<string, string> {
const map: Record<string, string> = {};
for (const app of apps) {
map[app.label.toLowerCase()] = app.packageName;
}
return map;
}
/**
* Try to find an app name at the start of a goal string.
* Checks device's installed apps first, then falls back to hardcoded map.
* Returns the package name and remaining text, or null. * Returns the package name and remaining text, or null.
*/ */
function matchAppName(lower: string): { pkg: string; appName: string; rest: string } | null { function matchAppName(
lower: string,
installedApps: InstalledApp[]
): { pkg: string; appName: string; rest: string } | null {
// Build combined lookup: installed apps take priority over fallback
const installedMap = buildInstalledAppMap(installedApps);
const combined: Record<string, string> = { ...FALLBACK_PACKAGES, ...installedMap };
// Try longest app names first (e.g. "google meet" before "meet") // Try longest app names first (e.g. "google meet" before "meet")
const sorted = Object.keys(APP_PACKAGES).sort((a, b) => b.length - a.length); const sorted = Object.keys(combined).sort((a, b) => b.length - a.length);
for (const name of sorted) { for (const name of sorted) {
// Match: "open <app> [app] and <rest>" or "open <app> [app]" // Match: "open <app> [app] and <rest>" or "open <app> [app]"
@@ -93,7 +124,7 @@ function matchAppName(lower: string): { pkg: string; appName: string; rest: stri
); );
const m = lower.match(pattern); const m = lower.match(pattern);
if (m) { if (m) {
return { pkg: APP_PACKAGES[name], appName: name, rest: m[1]?.trim() ?? "" }; return { pkg: combined[name], appName: name, rest: m[1]?.trim() ?? "" };
} }
} }
return null; return null;
@@ -103,6 +134,23 @@ function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
/**
* Fetch installed apps from the device record in DB.
*/
async function fetchInstalledApps(persistentDeviceId: string): Promise<InstalledApp[]> {
try {
const rows = await db
.select({ info: deviceTable.deviceInfo })
.from(deviceTable)
.where(eq(deviceTable.id, persistentDeviceId))
.limit(1);
const info = rows[0]?.info as Record<string, unknown> | null;
return (info?.installedApps as InstalledApp[]) ?? [];
} catch {
return [];
}
}
/** /**
* Attempt to preprocess a goal before the LLM loop. * Attempt to preprocess a goal before the LLM loop.
* *
@@ -113,12 +161,16 @@ function escapeRegex(s: string): string {
*/ */
export async function preprocessGoal( export async function preprocessGoal(
deviceId: string, deviceId: string,
goal: string goal: string,
persistentDeviceId?: string
): Promise<PreprocessResult> { ): Promise<PreprocessResult> {
const lower = goal.toLowerCase().trim(); const lower = goal.toLowerCase().trim();
// Fetch device's actual installed apps for accurate package resolution
const installedApps = persistentDeviceId ? await fetchInstalledApps(persistentDeviceId) : [];
// ── Pattern: "open <app> [and <remaining>]" ─────────────── // ── Pattern: "open <app> [and <remaining>]" ───────────────
const appMatch = matchAppName(lower); const appMatch = matchAppName(lower, installedApps);
if (appMatch) { if (appMatch) {
try { try {

View File

@@ -1,5 +1,8 @@
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { auth } from "../auth.js"; import { db } from "../db.js";
import { session as sessionTable, user as userTable } from "../schema.js";
import { eq } from "drizzle-orm";
import { getCookie } from "hono/cookie";
/** Hono Env type for routes protected by sessionMiddleware */ /** Hono Env type for routes protected by sessionMiddleware */
export type AuthEnv = { export type AuthEnv = {
@@ -10,15 +13,43 @@ export type AuthEnv = {
}; };
export async function sessionMiddleware(c: Context, next: Next) { export async function sessionMiddleware(c: Context, next: Next) {
const session = await auth.api.getSession({ // Extract session token from cookie (same approach as dashboard WS auth)
headers: c.req.raw.headers, const rawCookie = getCookie(c, "better-auth.session_token");
}); if (!rawCookie) {
if (!session) {
return c.json({ error: "unauthorized" }, 401); return c.json({ error: "unauthorized" }, 401);
} }
c.set("user", session.user); // Token may have a signature appended after a dot — use only the token part
c.set("session", session.session); const token = rawCookie.split(".")[0];
// Direct DB lookup (proven to work, unlike auth.api.getSession)
const rows = await db
.select({
sessionId: sessionTable.id,
userId: sessionTable.userId,
})
.from(sessionTable)
.where(eq(sessionTable.token, token))
.limit(1);
if (rows.length === 0) {
return c.json({ error: "unauthorized" }, 401);
}
const { sessionId, userId } = rows[0];
// Fetch user info
const users = await db
.select({ id: userTable.id, name: userTable.name, email: userTable.email })
.from(userTable)
.where(eq(userTable.id, userId))
.limit(1);
if (users.length === 0) {
return c.json({ error: "unauthorized" }, 401);
}
c.set("user", users[0]);
c.set("session", { id: sessionId, userId });
await next(); await next();
} }

View File

@@ -1,14 +1,17 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { eq } from "drizzle-orm";
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js"; import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
import { sessions } from "../ws/sessions.js"; import { sessions } from "../ws/sessions.js";
import { runAgentLoop, type AgentLoopOptions } from "../agent/loop.js"; import { runAgentLoop, type AgentLoopOptions } from "../agent/loop.js";
import type { LLMConfig } from "../agent/llm.js"; import type { LLMConfig } from "../agent/llm.js";
import { db } from "../db.js";
import { llmConfig as llmConfigTable } from "../schema.js";
const goals = new Hono<AuthEnv>(); const goals = new Hono<AuthEnv>();
goals.use("*", sessionMiddleware); goals.use("*", sessionMiddleware);
/** Track running agent sessions so we can prevent duplicates */ /** Track running agent sessions so we can prevent duplicates and cancel them */
const activeSessions = new Map<string, { sessionId: string; goal: string }>(); const activeSessions = new Map<string, { sessionId: string; goal: string; abort: AbortController }>();
goals.post("/", async (c) => { goals.post("/", async (c) => {
const user = c.get("user"); const user = c.get("user");
@@ -46,15 +49,39 @@ goals.post("/", async (c) => {
); );
} }
// Build LLM config from request body or environment defaults // Build LLM config: request body → user's DB config → env defaults
const llmConfig: LLMConfig = { let llmCfg: LLMConfig;
if (body.llmApiKey) {
llmCfg = {
provider: body.llmProvider ?? process.env.LLM_PROVIDER ?? "openai", provider: body.llmProvider ?? process.env.LLM_PROVIDER ?? "openai",
apiKey: body.llmApiKey ?? process.env.LLM_API_KEY ?? "", apiKey: body.llmApiKey,
model: body.llmModel, model: body.llmModel,
}; };
} else {
// Fetch user's saved LLM config from DB (same as device WS handler)
const configs = await db
.select()
.from(llmConfigTable)
.where(eq(llmConfigTable.userId, user.id))
.limit(1);
if (!llmConfig.apiKey) { if (configs.length > 0) {
return c.json({ error: "LLM API key is required (provide llmApiKey or set LLM_API_KEY env var)" }, 400); const cfg = configs[0];
llmCfg = {
provider: cfg.provider,
apiKey: cfg.apiKey,
model: body.llmModel ?? cfg.model ?? undefined,
};
} else if (process.env.LLM_API_KEY) {
llmCfg = {
provider: process.env.LLM_PROVIDER ?? "openai",
apiKey: process.env.LLM_API_KEY,
model: body.llmModel,
};
} else {
return c.json({ error: "No LLM provider configured. Set it up in the web dashboard Settings." }, 400);
}
} }
const options: AgentLoopOptions = { const options: AgentLoopOptions = {
@@ -62,16 +89,20 @@ goals.post("/", async (c) => {
persistentDeviceId: device.persistentDeviceId, persistentDeviceId: device.persistentDeviceId,
userId: user.id, userId: user.id,
goal: body.goal, goal: body.goal,
llmConfig, llmConfig: llmCfg,
maxSteps: body.maxSteps, maxSteps: body.maxSteps,
}; };
// Create abort controller for this session
const abort = new AbortController();
options.signal = abort.signal;
// Start the agent loop in the background (fire-and-forget). // Start the agent loop in the background (fire-and-forget).
// The client observes progress via the /ws/dashboard WebSocket. // The client observes progress via the /ws/dashboard WebSocket.
const loopPromise = runAgentLoop(options); const loopPromise = runAgentLoop(options);
// Track as active until it completes // Track as active until it completes
const sessionPlaceholder = { sessionId: "pending", goal: body.goal }; const sessionPlaceholder = { sessionId: "pending", goal: body.goal, abort };
activeSessions.set(trackingKey, sessionPlaceholder); activeSessions.set(trackingKey, sessionPlaceholder);
loopPromise loopPromise
@@ -96,4 +127,33 @@ goals.post("/", async (c) => {
}); });
}); });
goals.post("/stop", async (c) => {
const user = c.get("user");
const body = await c.req.json<{ deviceId: string }>();
if (!body.deviceId) {
return c.json({ error: "deviceId is required" }, 400);
}
// Look up device to verify ownership
const device = sessions.getDevice(body.deviceId)
?? sessions.getDeviceByPersistentId(body.deviceId);
if (!device) {
return c.json({ error: "device not connected" }, 404);
}
if (device.userId !== user.id) {
return c.json({ error: "device does not belong to you" }, 403);
}
const trackingKey = device.persistentDeviceId ?? device.deviceId;
const active = activeSessions.get(trackingKey);
if (!active) {
return c.json({ error: "no agent running on this device" }, 404);
}
active.abort.abort();
console.log(`[Agent] Stop requested for device ${body.deviceId}`);
return c.json({ status: "stopping" });
});
export { goals }; export { goals };

View File

@@ -227,7 +227,7 @@ export async function handleDeviceMessage(
// Preprocess: handle simple goals directly, or extract "open X" prefix // Preprocess: handle simple goals directly, or extract "open X" prefix
let effectiveGoal = goal; let effectiveGoal = goal;
try { try {
const preResult = await preprocessGoal(deviceId, goal); const preResult = await preprocessGoal(deviceId, goal, persistentDeviceId);
if (preResult.handled) { if (preResult.handled) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
@@ -328,6 +328,26 @@ export async function handleDeviceMessage(
break; break;
} }
case "apps": {
const persistentDeviceId = ws.data.persistentDeviceId;
if (persistentDeviceId) {
const apps = (msg as unknown as { apps: Array<{ packageName: string; label: string }> }).apps;
// Merge apps into existing deviceInfo
db.update(device)
.set({
deviceInfo: {
...(await db.select({ info: device.deviceInfo }).from(device).where(eq(device.id, persistentDeviceId)).limit(1).then(r => (r[0]?.info as Record<string, unknown>) ?? {})),
installedApps: apps,
},
})
.where(eq(device.id, persistentDeviceId))
.catch((err) => console.error(`[DB] Failed to store installed apps: ${err}`));
console.log(`[Device] Received ${apps.length} installed apps for device ${persistentDeviceId}`);
}
break;
}
case "heartbeat": { case "heartbeat": {
const persistentDeviceId = ws.data.persistentDeviceId; const persistentDeviceId = ws.data.persistentDeviceId;
const userId = ws.data.userId; const userId = ws.data.userId;

View File

@@ -4,6 +4,7 @@ import { building } from '$app/environment';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: event.request.headers headers: event.request.headers
}); });
@@ -11,6 +12,12 @@ export const handle: Handle = async ({ event, resolve }) => {
if (session) { if (session) {
event.locals.session = session.session; event.locals.session = session.session;
event.locals.user = session.user; event.locals.user = session.user;
} else if (event.url.pathname.startsWith('/api/')) {
console.log(`[Auth] No session for ${event.request.method} ${event.url.pathname}`);
console.log(`[Auth] Cookie header: ${event.request.headers.get('cookie')?.slice(0, 80) ?? 'NONE'}`);
}
} catch (err) {
console.error(`[Auth] getSession error for ${event.request.method} ${event.url.pathname}:`, err);
} }
return svelteKitHandler({ event, resolve, auth, building }); return svelteKitHandler({ event, resolve, auth, building });

View File

@@ -1,5 +1,6 @@
import * as v from 'valibot'; import * as v from 'valibot';
import { query, getRequestEvent } from '$app/server'; import { query, command, getRequestEvent } from '$app/server';
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { device, agentSession, agentStep } from '$lib/server/db/schema'; import { device, agentSession, agentStep } from '$lib/server/db/schema';
import { eq, desc, and, count, avg, sql, inArray } from 'drizzle-orm'; import { eq, desc, and, count, avg, sql, inArray } from 'drizzle-orm';
@@ -85,7 +86,8 @@ export const getDevice = query(v.string(), async (deviceId) => {
screenHeight: (info?.screenHeight as number) ?? null, screenHeight: (info?.screenHeight as number) ?? null,
batteryLevel: (info?.batteryLevel as number) ?? null, batteryLevel: (info?.batteryLevel as number) ?? null,
isCharging: (info?.isCharging as boolean) ?? false, isCharging: (info?.isCharging as boolean) ?? false,
lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString() lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString(),
installedApps: (info?.installedApps as Array<{ packageName: string; label: string }>) ?? []
}; };
}); });
@@ -155,3 +157,37 @@ export const listSessionSteps = query(
return steps; return steps;
} }
); );
// ─── Commands (write operations) ─────────────────────────────
const SERVER_URL = () => env.SERVER_URL || 'http://localhost:8080';
/** Forward a request to the DroidClaw server with auth cookies */
async function serverFetch(path: string, body: Record<string, unknown>) {
const { request } = getRequestEvent();
const res = await fetch(`${SERVER_URL()}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: request.headers.get('cookie') ?? ''
},
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({ error: 'Unknown error' }));
if (!res.ok) throw new Error(data.error ?? `Error ${res.status}`);
return data;
}
export const submitGoal = command(
v.object({ deviceId: v.string(), goal: v.string() }),
async ({ deviceId, goal }) => {
return serverFetch('/goals', { deviceId, goal });
}
);
export const stopGoal = command(
v.object({ deviceId: v.string() }),
async ({ deviceId }) => {
return serverFetch('/goals/stop', { deviceId });
}
);

View File

@@ -1,29 +0,0 @@
import { json, error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
const SERVER_URL = env.SERVER_URL || 'http://localhost:8080';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
return error(401, 'Unauthorized');
}
const body = await request.json();
const res = await fetch(`${SERVER_URL}/goals`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: request.headers.get('cookie') ?? ''
},
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
return error(res.status, err.error ?? 'Failed to submit goal');
}
return json(await res.json());
};

View File

@@ -4,7 +4,9 @@
getDevice, getDevice,
listDeviceSessions, listDeviceSessions,
listSessionSteps, listSessionSteps,
getDeviceStats getDeviceStats,
submitGoal as submitGoalCmd,
stopGoal as stopGoalCmd
} from '$lib/api/devices.remote'; } from '$lib/api/devices.remote';
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte'; import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -27,6 +29,7 @@
batteryLevel: number | null; batteryLevel: number | null;
isCharging: boolean; isCharging: boolean;
lastSeen: string; lastSeen: string;
installedApps: Array<{ packageName: string; label: string }>;
} | null; } | null;
// Device stats // Device stats
@@ -80,23 +83,33 @@
} }
} }
let runError = $state('');
async function submitGoal() { async function submitGoal() {
if (!goal.trim()) return; if (!goal.trim()) return;
runStatus = 'running'; runStatus = 'running';
runError = '';
currentGoal = goal; currentGoal = goal;
steps = []; steps = [];
const res = await fetch('/api/goals', { try {
method: 'POST', await submitGoalCmd({ deviceId, goal });
headers: { 'Content-Type': 'application/json' }, } catch (e: any) {
body: JSON.stringify({ deviceId, goal }) runError = e.message ?? String(e);
});
if (!res.ok) {
runStatus = 'failed'; runStatus = 'failed';
} }
} }
async function stopGoal() {
try {
await stopGoalCmd({ deviceId });
runStatus = 'failed';
runError = 'Stopped by user';
} catch {
// ignore
}
}
onMount(() => { onMount(() => {
const unsub = dashboardWs.subscribe((msg) => { const unsub = dashboardWs.subscribe((msg) => {
switch (msg.type) { switch (msg.type) {
@@ -159,6 +172,16 @@
return `${days}d ago`; return `${days}d ago`;
} }
let appSearch = $state('');
const filteredApps = $derived(
(deviceData?.installedApps ?? []).filter(
(a) =>
!appSearch ||
a.label.toLowerCase().includes(appSearch.toLowerCase()) ||
a.packageName.toLowerCase().includes(appSearch.toLowerCase())
)
);
const battery = $derived(liveBattery ?? (deviceData?.batteryLevel as number | null)); const battery = $derived(liveBattery ?? (deviceData?.batteryLevel as number | null));
const charging = $derived(liveCharging || (deviceData?.isCharging as boolean)); const charging = $derived(liveCharging || (deviceData?.isCharging as boolean));
</script> </script>
@@ -279,6 +302,34 @@
</div> </div>
</div> </div>
<!-- Installed Apps -->
{#if deviceData && deviceData.installedApps.length > 0}
<div class="mt-4 rounded-lg border border-neutral-200">
<div class="flex items-center justify-between border-b border-neutral-100 px-5 py-3">
<h3 class="text-sm font-semibold uppercase tracking-wide text-neutral-500">
Installed Apps
<span class="ml-1 font-normal normal-case text-neutral-400">({deviceData.installedApps.length})</span>
</h3>
<input
type="text"
bind:value={appSearch}
placeholder="Search apps..."
class="w-48 rounded border border-neutral-200 px-2.5 py-1 text-xs focus:border-neutral-400 focus:outline-none"
/>
</div>
<div class="max-h-72 overflow-y-auto">
{#each filteredApps as app (app.packageName)}
<div class="flex items-center justify-between px-5 py-2 text-sm hover:bg-neutral-50">
<span class="font-medium">{app.label}</span>
<span class="font-mono text-xs text-neutral-400">{app.packageName}</span>
</div>
{:else}
<p class="px-5 py-3 text-xs text-neutral-400">No apps match "{appSearch}"</p>
{/each}
</div>
</div>
{/if}
<!-- Sessions Tab --> <!-- Sessions Tab -->
{:else if activeTab === 'sessions'} {:else if activeTab === 'sessions'}
{#if sessions.length === 0} {#if sessions.length === 0}
@@ -359,13 +410,21 @@
disabled={runStatus === 'running'} disabled={runStatus === 'running'}
onkeydown={(e) => e.key === 'Enter' && submitGoal()} onkeydown={(e) => e.key === 'Enter' && submitGoal()}
/> />
{#if runStatus === 'running'}
<button
onclick={stopGoal}
class="rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-500"
>
Stop
</button>
{:else}
<button <button
onclick={submitGoal} onclick={submitGoal}
disabled={runStatus === 'running'} class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700"
class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700 disabled:opacity-50"
> >
{runStatus === 'running' ? 'Running...' : 'Run'} Run
</button> </button>
{/if}
</div> </div>
</div> </div>
@@ -391,6 +450,11 @@
<span class="text-xs text-red-600">Failed</span> <span class="text-xs text-red-600">Failed</span>
{/if} {/if}
</div> </div>
{#if runError}
<div class="border-t border-red-100 bg-red-50 px-5 py-3 text-xs text-red-700">
{runError}
</div>
{/if}
{#if steps.length > 0} {#if steps.length > 0}
<div class="divide-y divide-neutral-100"> <div class="divide-y divide-neutral-100">
{#each steps as s (s.step)} {#each steps as s (s.step)}