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