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:
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:name=".DroidClawApp"
|
||||
|
||||
@@ -41,12 +41,21 @@ class DroidClawAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
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) {
|
||||
val root = rootInActiveWindow
|
||||
if (root != null) {
|
||||
try {
|
||||
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
|
||||
return elements
|
||||
} finally {
|
||||
@@ -55,7 +64,7 @@ class DroidClawAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
runBlocking { delay(delayMs) }
|
||||
}
|
||||
Log.w(TAG, "rootInActiveWindow null after retries")
|
||||
Log.w(TAG, "rootInActiveWindow null or empty after retries")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ import com.thisux.droidclaw.model.GoalMessage
|
||||
import com.thisux.droidclaw.model.GoalStatus
|
||||
import com.thisux.droidclaw.model.AgentStep
|
||||
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 android.content.pm.PackageManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -125,6 +128,12 @@ class ConnectionService : LifecycleService() {
|
||||
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 } }
|
||||
@@ -179,6 +188,17 @@ class ConnectionService : LifecycleService() {
|
||||
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() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
|
||||
@@ -26,7 +26,7 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
|
||||
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 ->
|
||||
|
||||
@@ -58,6 +58,18 @@ data class HeartbeatMessage(
|
||||
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
|
||||
data class ServerMessage(
|
||||
val type: String,
|
||||
|
||||
@@ -51,7 +51,7 @@ fun SettingsScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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 editingServerUrl by remember(serverUrl) { mutableStateOf(serverUrl) }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UIElement, DeviceInfo } from "./types.js";
|
||||
import type { UIElement, DeviceInfo, InstalledApp } from "./types.js";
|
||||
|
||||
export type DeviceMessage =
|
||||
| { type: "auth"; apiKey: string; deviceInfo?: DeviceInfo }
|
||||
@@ -6,7 +6,8 @@ export type DeviceMessage =
|
||||
| { type: "result"; requestId: string; success: boolean; error?: string; data?: string }
|
||||
| { type: "goal"; text: string }
|
||||
| { type: "pong" }
|
||||
| { type: "heartbeat"; batteryLevel: number; isCharging: boolean };
|
||||
| { type: "heartbeat"; batteryLevel: number; isCharging: boolean }
|
||||
| { type: "apps"; apps: InstalledApp[] };
|
||||
|
||||
export type ServerToDeviceMessage =
|
||||
| { type: "auth_ok"; deviceId: string }
|
||||
|
||||
@@ -61,6 +61,11 @@ export interface DeviceInfo {
|
||||
isCharging: boolean;
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
packageName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ScreenState {
|
||||
elements: UIElement[];
|
||||
screenshot?: string;
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "./llm.js";
|
||||
import { createStuckDetector } from "./stuck.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 type { UIElement, ActionDecision } from "@droidclaw/shared";
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface AgentLoopOptions {
|
||||
originalGoal?: string;
|
||||
llmConfig: LLMConfig;
|
||||
maxSteps?: number;
|
||||
/** Abort signal for cancellation */
|
||||
signal?: AbortSignal;
|
||||
onStep?: (step: AgentStep) => void;
|
||||
onComplete?: (result: AgentResult) => void;
|
||||
}
|
||||
@@ -224,6 +226,7 @@ export async function runAgentLoop(
|
||||
originalGoal,
|
||||
llmConfig,
|
||||
maxSteps = 30,
|
||||
signal,
|
||||
onStep,
|
||||
onComplete,
|
||||
} = options;
|
||||
@@ -239,6 +242,28 @@ export async function runAgentLoop(
|
||||
const recentActions: string[] = [];
|
||||
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
|
||||
if (persistentDeviceId) {
|
||||
try {
|
||||
@@ -268,6 +293,12 @@ export async function runAgentLoop(
|
||||
|
||||
try {
|
||||
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;
|
||||
|
||||
// ── 1. Get screen state from device ─────────────────────
|
||||
@@ -371,6 +402,7 @@ export async function runAgentLoop(
|
||||
let userPrompt =
|
||||
`GOAL: ${goal}\n\n` +
|
||||
`STEP: ${step + 1}/${maxSteps}\n\n` +
|
||||
installedAppsContext +
|
||||
foregroundLine +
|
||||
actionFeedbackLine +
|
||||
`SCREEN_CONTEXT:\n${JSON.stringify(elements, null, 2)}` +
|
||||
|
||||
@@ -8,10 +8,21 @@
|
||||
*/
|
||||
|
||||
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",
|
||||
gmail: "com.google.android.gm",
|
||||
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.
|
||||
*/
|
||||
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")
|
||||
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) {
|
||||
// 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);
|
||||
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;
|
||||
@@ -103,6 +134,23 @@ function escapeRegex(s: string): string {
|
||||
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.
|
||||
*
|
||||
@@ -113,12 +161,16 @@ function escapeRegex(s: string): string {
|
||||
*/
|
||||
export async function preprocessGoal(
|
||||
deviceId: string,
|
||||
goal: string
|
||||
goal: string,
|
||||
persistentDeviceId?: string
|
||||
): Promise<PreprocessResult> {
|
||||
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>]" ───────────────
|
||||
const appMatch = matchAppName(lower);
|
||||
const appMatch = matchAppName(lower, installedApps);
|
||||
|
||||
if (appMatch) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 */
|
||||
export type AuthEnv = {
|
||||
@@ -10,15 +13,43 @@ export type AuthEnv = {
|
||||
};
|
||||
|
||||
export async function sessionMiddleware(c: Context, next: Next) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
// Extract session token from cookie (same approach as dashboard WS auth)
|
||||
const rawCookie = getCookie(c, "better-auth.session_token");
|
||||
if (!rawCookie) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
c.set("user", session.user);
|
||||
c.set("session", session.session);
|
||||
// Token may have a signature appended after a dot — use only the token part
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Hono } from "hono";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
|
||||
import { sessions } from "../ws/sessions.js";
|
||||
import { runAgentLoop, type AgentLoopOptions } from "../agent/loop.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>();
|
||||
goals.use("*", sessionMiddleware);
|
||||
|
||||
/** Track running agent sessions so we can prevent duplicates */
|
||||
const activeSessions = new Map<string, { sessionId: string; goal: string }>();
|
||||
/** Track running agent sessions so we can prevent duplicates and cancel them */
|
||||
const activeSessions = new Map<string, { sessionId: string; goal: string; abort: AbortController }>();
|
||||
|
||||
goals.post("/", async (c) => {
|
||||
const user = c.get("user");
|
||||
@@ -46,15 +49,39 @@ goals.post("/", async (c) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Build LLM config from request body or environment defaults
|
||||
const llmConfig: LLMConfig = {
|
||||
provider: body.llmProvider ?? process.env.LLM_PROVIDER ?? "openai",
|
||||
apiKey: body.llmApiKey ?? process.env.LLM_API_KEY ?? "",
|
||||
model: body.llmModel,
|
||||
};
|
||||
// Build LLM config: request body → user's DB config → env defaults
|
||||
let llmCfg: LLMConfig;
|
||||
|
||||
if (!llmConfig.apiKey) {
|
||||
return c.json({ error: "LLM API key is required (provide llmApiKey or set LLM_API_KEY env var)" }, 400);
|
||||
if (body.llmApiKey) {
|
||||
llmCfg = {
|
||||
provider: body.llmProvider ?? process.env.LLM_PROVIDER ?? "openai",
|
||||
apiKey: body.llmApiKey,
|
||||
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 (configs.length > 0) {
|
||||
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 = {
|
||||
@@ -62,16 +89,20 @@ goals.post("/", async (c) => {
|
||||
persistentDeviceId: device.persistentDeviceId,
|
||||
userId: user.id,
|
||||
goal: body.goal,
|
||||
llmConfig,
|
||||
llmConfig: llmCfg,
|
||||
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).
|
||||
// The client observes progress via the /ws/dashboard WebSocket.
|
||||
const loopPromise = runAgentLoop(options);
|
||||
|
||||
// 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);
|
||||
|
||||
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 };
|
||||
|
||||
@@ -227,7 +227,7 @@ export async function handleDeviceMessage(
|
||||
// Preprocess: handle simple goals directly, or extract "open X" prefix
|
||||
let effectiveGoal = goal;
|
||||
try {
|
||||
const preResult = await preprocessGoal(deviceId, goal);
|
||||
const preResult = await preprocessGoal(deviceId, goal, persistentDeviceId);
|
||||
if (preResult.handled) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
@@ -328,6 +328,26 @@ export async function handleDeviceMessage(
|
||||
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": {
|
||||
const persistentDeviceId = ws.data.persistentDeviceId;
|
||||
const userId = ws.data.userId;
|
||||
|
||||
@@ -4,13 +4,20 @@ import { building } from '$app/environment';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
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 });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { device, agentSession, agentStep } from '$lib/server/db/schema';
|
||||
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,
|
||||
batteryLevel: (info?.batteryLevel as number) ?? null,
|
||||
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;
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 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 });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -4,7 +4,9 @@
|
||||
getDevice,
|
||||
listDeviceSessions,
|
||||
listSessionSteps,
|
||||
getDeviceStats
|
||||
getDeviceStats,
|
||||
submitGoal as submitGoalCmd,
|
||||
stopGoal as stopGoalCmd
|
||||
} from '$lib/api/devices.remote';
|
||||
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -27,6 +29,7 @@
|
||||
batteryLevel: number | null;
|
||||
isCharging: boolean;
|
||||
lastSeen: string;
|
||||
installedApps: Array<{ packageName: string; label: string }>;
|
||||
} | null;
|
||||
|
||||
// Device stats
|
||||
@@ -80,23 +83,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
let runError = $state('');
|
||||
|
||||
async function submitGoal() {
|
||||
if (!goal.trim()) return;
|
||||
runStatus = 'running';
|
||||
runError = '';
|
||||
currentGoal = goal;
|
||||
steps = [];
|
||||
|
||||
const res = await fetch('/api/goals', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceId, goal })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
try {
|
||||
await submitGoalCmd({ deviceId, goal });
|
||||
} catch (e: any) {
|
||||
runError = e.message ?? String(e);
|
||||
runStatus = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopGoal() {
|
||||
try {
|
||||
await stopGoalCmd({ deviceId });
|
||||
runStatus = 'failed';
|
||||
runError = 'Stopped by user';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsub = dashboardWs.subscribe((msg) => {
|
||||
switch (msg.type) {
|
||||
@@ -159,6 +172,16 @@
|
||||
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 charging = $derived(liveCharging || (deviceData?.isCharging as boolean));
|
||||
</script>
|
||||
@@ -279,6 +302,34 @@
|
||||
</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 -->
|
||||
{:else if activeTab === 'sessions'}
|
||||
{#if sessions.length === 0}
|
||||
@@ -359,13 +410,21 @@
|
||||
disabled={runStatus === 'running'}
|
||||
onkeydown={(e) => e.key === 'Enter' && submitGoal()}
|
||||
/>
|
||||
<button
|
||||
onclick={submitGoal}
|
||||
disabled={runStatus === 'running'}
|
||||
class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700 disabled:opacity-50"
|
||||
>
|
||||
{runStatus === 'running' ? 'Running...' : 'Run'}
|
||||
</button>
|
||||
{#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
|
||||
onclick={submitGoal}
|
||||
class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700"
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -391,6 +450,11 @@
|
||||
<span class="text-xs text-red-600">Failed</span>
|
||||
{/if}
|
||||
</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}
|
||||
<div class="divide-y divide-neutral-100">
|
||||
{#each steps as s (s.step)}
|
||||
|
||||
Reference in New Issue
Block a user