diff --git a/README.md b/README.md index 64a44b6..423d2ba 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ Currently in alpha. Greenfield. Subject to change. ### Device Management (Orchestrator + Admin) -- [ ] Device schema — DB model for a device (host VPS, container ID, status, `inUse`, assigned session, etc.) -- [ ] Device domain in `@pkg/logic` — controller + repository + errors +- [x] Device schema — DB model for a device (host VPS, container ID, status, `inUse`, assigned session, etc.) +- [x] Device domain in `@pkg/logic` — controller + repository + errors - [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls: - [ ] `POST /devices/:id/start` — start a Docker-Android container - [ ] `POST /devices/:id/stop` — stop a container @@ -45,14 +45,14 @@ Currently in alpha. Greenfield. Subject to change. - [ ] `GET /devices/:id` — page to view the device in more detail (info, live stream feed with ws-scrcpy) - [ ] Device allocation logic — atomically mark a device as `inUse` when a validated link starts a session - [ ] Device release logic — clear `inUse` when a session ends or fails during setup -- [ ] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart +- [x] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart - [ ] Internal API key auth between `apps/main` and `apps/orchestrator` ### Link Management (Admin + Front App) -- [ ] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity) -- [ ] Link domain in `@pkg/logic` — controller + repository + errors -- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete +- [x] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity) +- [x] Link domain in `@pkg/logic` — controller + repository + errors +- [x] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete - [ ] `apps/front`: validate incoming link token on request - [ ] `apps/front`: during loading, reject the link if the assigned device is already `inUse` - [ ] `apps/front`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session @@ -69,8 +69,8 @@ Currently in alpha. Greenfield. Subject to change. ### Android Streaming (scrcpy + ws-scrcpy) -- [ ] Docker-Android image setup and validation on VPS -- [ ] ws-scrcpy WebSocket server running per container, exposed via orchestrator +- [x] Docker-Android image setup and validation on VPS +- [x] ws-scrcpy WebSocket server running per container, exposed via orchestrator - [ ] `apps/front`: scrcpy client embedded in PWA — renders the Android stream in browser - [ ] Input forwarding (touch/keyboard events → scrcpy → Android container) - [ ] Session timeout + stream teardown on inactivity diff --git a/apps/front/old.server.ts b/apps/front/old.server.ts index e6de9a7..cc5d571 100644 --- a/apps/front/old.server.ts +++ b/apps/front/old.server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * Mobile Proxy Test — Bun Reverse Proxy Server * diff --git a/apps/front/src/core/utils.ts b/apps/front/src/core/utils.ts new file mode 100644 index 0000000..209b471 --- /dev/null +++ b/apps/front/src/core/utils.ts @@ -0,0 +1,54 @@ +import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context"; +import { ERROR_CODES, errorStatusMap, type Err } from "@pkg/result"; +import { randomUUID } from "node:crypto"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +export function buildFlowExecCtx(): FlowExecCtx { + return { flowId: randomUUID() }; +} + +export function normalizeBaseUrl(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +export function createAppError( + fctx: FlowExecCtx, + code: string, + message: string, + description: string, + detail: string, + actionable?: boolean, +): Err { + return { + flowId: fctx.flowId, + code, + message, + description, + detail, + actionable, + }; +} + +export function jsonError(error: Err, status?: number) { + return { + body: { + data: null, + error: { + ...error, + }, + }, + status: + status || + errorStatusMap[error.code] || + errorStatusMap[ERROR_CODES.INTERNAL_SERVER_ERROR] || + 500, + }; +} + +export function toStatusCode(status: number): ContentfulStatusCode { + return status as ContentfulStatusCode; +} + +export function isErrPayload(value: unknown): value is Err { + return !!value && typeof value === "object" && "code" in value; +} diff --git a/apps/front/src/domains/links/router.ts b/apps/front/src/domains/links/router.ts new file mode 100644 index 0000000..91b6a3b --- /dev/null +++ b/apps/front/src/domains/links/router.ts @@ -0,0 +1,13 @@ +import { Hono } from "hono"; +import { buildFlowExecCtx, toStatusCode } from "../../core/utils"; +import { prepareLink, resolveLink } from "./service"; + +export const linksRouter = new Hono() + .get("/links/:token/resolve", async (c) => { + const response = await resolveLink(buildFlowExecCtx(), c.req.param("token")); + return c.json(response.body, toStatusCode(response.status)); + }) + .post("/links/:token/prepare", async (c) => { + const response = await prepareLink(buildFlowExecCtx(), c.req.param("token")); + return c.json(response.body, toStatusCode(response.status)); + }); diff --git a/apps/front/src/domains/links/service.ts b/apps/front/src/domains/links/service.ts new file mode 100644 index 0000000..edbb37d --- /dev/null +++ b/apps/front/src/domains/links/service.ts @@ -0,0 +1,297 @@ +import { getLinkController } from "@pkg/logic/domains/link/controller"; +import { DeviceStatus } from "@pkg/logic/domains/device/data"; +import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context"; +import { formatErrorDetail, logDomainEvent } from "@pkg/logger"; +import { ERROR_CODES, type Err } from "@pkg/result"; +import { settings } from "@pkg/settings"; +import { + createAppError, + isErrPayload, + jsonError, + normalizeBaseUrl, +} from "../../core/utils"; + +const linkController = getLinkController(); + +type OrchestratorPreparePayload = { + deviceId: number; + packageName: string; + linkToken: string; +}; + +type OrchestratorPrepareResponse = { + data: { + deviceId: number; + containerId: string; + packageName: string; + serial: string; + status: string; + }; + error: null; +}; + +async function callOrchestratorPrepare( + fctx: FlowExecCtx, + payload: OrchestratorPreparePayload, +): Promise { + const response = await fetch( + `${normalizeBaseUrl(settings.orchestratorApiUrl)}/internal/sessions/prepare`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-internal-api-key": settings.internalApiKey, + "x-flow-id": fctx.flowId, + }, + body: JSON.stringify(payload), + }, + ); + + let body: unknown = null; + try { + body = await response.json(); + } catch (error) { + throw createAppError( + fctx, + ERROR_CODES.EXTERNAL_SERVICE_ERROR, + "Invalid orchestrator response", + "The orchestrator returned a response that could not be parsed", + formatErrorDetail(error), + ); + } + + if (!response.ok && body && typeof body === "object" && "error" in body) { + const payloadError = (body as { error?: unknown }).error; + if (isErrPayload(payloadError)) { + throw payloadError; + } + } + + if ( + !body || + typeof body !== "object" || + !("data" in body) || + ("error" in body && body.error) + ) { + throw createAppError( + fctx, + ERROR_CODES.EXTERNAL_SERVICE_ERROR, + "Unexpected orchestrator response", + "The orchestrator response was missing the expected data payload", + JSON.stringify(body), + ); + } + + return body as OrchestratorPrepareResponse; +} + +export async function resolveLink(fctx: FlowExecCtx, token: string) { + logDomainEvent({ + event: "front.link_resolve.started", + fctx, + meta: { token }, + }); + + const linkResult = await linkController.validate(fctx, token); + if (linkResult.isErr()) { + logDomainEvent({ + level: "warn", + event: "front.link_resolve.rejected", + fctx, + error: linkResult.error, + meta: { token }, + }); + + return jsonError(linkResult.error); + } + + const link = linkResult.value; + if (!link.device) { + return jsonError( + createAppError( + fctx, + ERROR_CODES.NOT_ALLOWED, + "Link is not assigned to a device", + "This link cannot start a session because no device is assigned", + `token=${token}`, + true, + ), + ); + } + + if (!link.supportedApp) { + return jsonError( + createAppError( + fctx, + ERROR_CODES.NOT_ALLOWED, + "Link is not assigned to an app", + "This link cannot start a session because no app is assigned", + `token=${token}`, + true, + ), + ); + } + + return { + body: { + data: { + link: { + id: link.id, + token: link.token, + status: link.status, + expiresAt: link.expiresAt, + }, + device: { + id: link.device.id, + title: link.device.title, + status: link.device.status, + inUse: link.device.inUse, + isAvailable: + link.device.status === DeviceStatus.ONLINE && !link.device.inUse, + }, + supportedApp: { + id: link.supportedApp.id, + title: link.supportedApp.title, + packageName: link.supportedApp.packageName, + }, + }, + error: null, + }, + status: 200, + }; +} + +export async function prepareLink(fctx: FlowExecCtx, token: string) { + logDomainEvent({ + event: "front.link_prepare.started", + fctx, + meta: { token }, + }); + + const linkResult = await linkController.validate(fctx, token); + if (linkResult.isErr()) { + logDomainEvent({ + level: "warn", + event: "front.link_prepare.rejected", + fctx, + error: linkResult.error, + meta: { token }, + }); + + return jsonError(linkResult.error); + } + + const link = linkResult.value; + if (!link.device) { + return jsonError( + createAppError( + fctx, + ERROR_CODES.NOT_ALLOWED, + "Link is not assigned to a device", + "This link cannot start a session because no device is assigned", + `token=${token}`, + true, + ), + ); + } + + if (!link.supportedApp) { + return jsonError( + createAppError( + fctx, + ERROR_CODES.NOT_ALLOWED, + "Link is not assigned to an app", + "This link cannot start a session because no app is assigned", + `token=${token}`, + true, + ), + ); + } + + if (link.device.status !== DeviceStatus.ONLINE || link.device.inUse) { + return jsonError( + createAppError( + fctx, + ERROR_CODES.NOT_ALLOWED, + "Device is not available", + "The assigned device is currently busy or offline", + `deviceId=${link.device.id} status=${link.device.status} inUse=${link.device.inUse}`, + true, + ), + ); + } + + try { + const orchestratorResponse = await callOrchestratorPrepare(fctx, { + deviceId: link.device.id, + packageName: link.supportedApp.packageName, + linkToken: link.token, + }); + + logDomainEvent({ + event: "front.link_prepare.succeeded", + fctx, + meta: { + token, + deviceId: link.device.id, + packageName: link.supportedApp.packageName, + }, + }); + + return { + body: { + data: { + link: { + id: link.id, + token: link.token, + }, + device: { + id: link.device.id, + title: link.device.title, + host: link.device.host, + wsPort: link.device.wsPort, + }, + supportedApp: { + id: link.supportedApp.id, + title: link.supportedApp.title, + packageName: link.supportedApp.packageName, + }, + session: orchestratorResponse.data, + }, + error: null, + }, + status: 200, + }; + } catch (error) { + const err: Err = isErrPayload(error) + ? error + : createAppError( + fctx, + ERROR_CODES.EXTERNAL_SERVICE_ERROR, + "Failed to prepare session", + "The front server could not prepare the assigned Android session", + formatErrorDetail(error), + ); + + logDomainEvent({ + level: "error", + event: "front.link_prepare.failed", + fctx, + error: err, + meta: { + token, + orchestratorUrl: settings.orchestratorApiUrl, + }, + }); + + return jsonError( + err.flowId + ? err + : { + ...err, + flowId: fctx.flowId, + }, + err.code === ERROR_CODES.EXTERNAL_SERVICE_ERROR ? 502 : undefined, + ); + } +} diff --git a/apps/front/src/index.ts b/apps/front/src/index.ts index c4b9227..b766601 100644 --- a/apps/front/src/index.ts +++ b/apps/front/src/index.ts @@ -1,33 +1,15 @@ import "./instrumentation.js"; import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry"; -import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context"; -import { logDomainEvent } from "@pkg/logger"; import { serve } from "@hono/node-server"; -import { settings } from "@pkg/settings"; -import { randomUUID } from "node:crypto"; import { Hono } from "hono"; +import { linksRouter } from "./domains/links/router"; const app = new Hono().use("*", createHttpTelemetryMiddleware("front")); const host = process.env.HOST || "0.0.0.0"; const port = Number(process.env.PORT || "3000"); -function normalizeBaseUrl(url: string): string { - return url.endsWith("/") ? url.slice(0, -1) : url; -} - -function buildFlowExecCtx(): FlowExecCtx { - return { flowId: randomUUID() }; -} - -function getClientDownloadedApkName(): string { - const filename = settings.clientDownloadedApkName.trim(); - return filename.toLowerCase().endsWith(".apk") - ? filename - : `${filename}.apk`; -} - app.get("/health", (c) => { return c.json({ ok: true }); }); @@ -36,110 +18,7 @@ app.get("/ping", (c) => { return c.text("pong"); }); -app.get("/downloads/file/:buildId", async (c) => { - const fctx = buildFlowExecCtx(); - const buildId = c.req.param("buildId"); - - logDomainEvent({ - event: "processor.apk_download.started", - fctx, - meta: { buildId }, - }); - - const buildResult = await mobileBuildController.validateActiveBuildId( - fctx, - buildId, - ); - if (buildResult.isErr()) { - logDomainEvent({ - level: "warn", - event: "processor.apk_download.rejected", - fctx, - error: buildResult.error, - meta: { buildId }, - }); - return c.json( - { - data: null, - error: { ...buildResult.error, flowId: fctx.flowId }, - }, - 404, - ); - } - - const build = buildResult.value; - if (!build.apkAssetPath) { - logDomainEvent({ - level: "warn", - event: "processor.apk_download.missing_artifact", - fctx, - meta: { buildId }, - }); - return c.json( - { - data: null, - error: { - flowId: fctx.flowId, - code: "NOT_FOUND", - message: "APK not available", - description: "This build does not have a generated APK yet", - detail: `buildId=${buildId}`, - }, - }, - 404, - ); - } - - const assetUrl = `${normalizeBaseUrl(settings.appBuilderApiUrl)}${build.apkAssetPath}`; - const assetResponse = await fetch(assetUrl); - - if (!assetResponse.ok || !assetResponse.body) { - logDomainEvent({ - level: "error", - event: "processor.apk_download.fetch_failed", - fctx, - meta: { - buildId, - assetUrl, - status: assetResponse.status, - }, - }); - return c.json( - { - data: null, - error: { - flowId: fctx.flowId, - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch APK artifact", - description: "Please try again later", - detail: `assetUrl=${assetUrl} status=${assetResponse.status}`, - }, - }, - 502, - ); - } - - logDomainEvent({ - event: "processor.apk_download.succeeded", - fctx, - meta: { - buildId, - assetUrl, - downloadName: getClientDownloadedApkName(), - }, - }); - - return new Response(assetResponse.body, { - status: 200, - headers: { - "content-type": - assetResponse.headers.get("content-type") || - "application/vnd.android.package-archive", - "content-disposition": `attachment; filename="${getClientDownloadedApkName()}"`, - "cache-control": "no-store", - }, - }); -}); +app.route("/", linksRouter); serve( { diff --git a/apps/main/src/lib/domains/device/device-details.vm.svelte.ts b/apps/main/src/lib/domains/device/device-details.vm.svelte.ts index 91d1d78..4b8d20f 100644 --- a/apps/main/src/lib/domains/device/device-details.vm.svelte.ts +++ b/apps/main/src/lib/domains/device/device-details.vm.svelte.ts @@ -27,7 +27,7 @@ function buildStreamUrl(host: string, wsPort: string): string | null { const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(/^http:/, "ws:"); const wsParam = - `${baseWss}/?action=proxy-adb&remote=tcp:${SCRCPY_SERVER_PORT}&udid=${encodeURIComponent(udid)}`; + `${baseWss}/?action=proxy-adb&remote=${encodeURIComponent(`tcp:${SCRCPY_SERVER_PORT}`)}&udid=${encodeURIComponent(udid)}`; const hash = `#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`; diff --git a/apps/main/src/lib/domains/link/link.vm.svelte.ts b/apps/main/src/lib/domains/link/link.vm.svelte.ts index e0c6403..2a52eec 100644 --- a/apps/main/src/lib/domains/link/link.vm.svelte.ts +++ b/apps/main/src/lib/domains/link/link.vm.svelte.ts @@ -26,10 +26,27 @@ class LinkViewModel { revokingId = $state(null); showCreateDialog = $state(false); + /** + * SvelteKit's query() caches the RemoteQuery object in an internal query_map. + * Subsequent calls to listLinksSQ() return the same cached Query with stale data. + * We must call refresh() on the cached query to force a fresh server request. + */ + private _linksQuery: ReturnType | null = null; + async fetchLinks() { this.loading = true; try { - const result = await listLinksSQ(); + const query = listLinksSQ(); + + // On subsequent calls the query is cached and stale — refresh it + if (query.ready) { + await query.refresh(); + } + + // After refresh, read .current; on first load, await the initial fetch + const result = query.ready ? query.current : await query; + this._linksQuery = query; + if (result?.error || !result?.data) { toast.error( result?.error?.message || "Failed to fetch links", diff --git a/apps/main/src/routes/(main)/dashboard/[id]/+page.svelte b/apps/main/src/routes/(main)/dashboard/[id]/+page.svelte index ebba701..9fb687e 100644 --- a/apps/main/src/routes/(main)/dashboard/[id]/+page.svelte +++ b/apps/main/src/routes/(main)/dashboard/[id]/+page.svelte @@ -1,4 +1,5 @@