supported apps domain + some refactor of data types redundancy

This commit is contained in:
user
2026-03-28 16:19:24 +02:00
parent 6639bcd799
commit 671a712b08
26 changed files with 2052 additions and 169 deletions

View File

@@ -1,4 +1,5 @@
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
import AppWindow from "@lucide/svelte/icons/app-window";
import { BellRingIcon, Link } from "@lucide/svelte"; import { BellRingIcon, Link } from "@lucide/svelte";
import UserCircle from "~icons/lucide/user-circle"; import UserCircle from "~icons/lucide/user-circle";
@@ -25,6 +26,11 @@ export const mainNavTree = [
url: "/links", url: "/links",
icon: Link, icon: Link,
}, },
{
title: "Supported Apps",
url: "/supported-apps",
icon: AppWindow,
},
] as AppSidebarItem[]; ] as AppSidebarItem[];
export const secondaryNavTree = [ export const secondaryNavTree = [
@@ -40,17 +46,7 @@ export const secondaryNavTree = [
}, },
] as AppSidebarItem[]; ] as AppSidebarItem[];
export const SUPPORTED_APPS = [ export const WS_SCRCPY_URL = "https://iotam-ws-scrcpy.snapyra.com";
{
title: "Gmail",
packageName: "com.google.android.gm",
},
{
title: "Outlook",
packageName: "com.microsoft.outlook",
},
// will add more here when support increases
];
export const COMPANY_NAME = "SaaS Template"; export const COMPANY_NAME = "SaaS Template";
export const WEBSITE_URL = "https://company.com"; export const WEBSITE_URL = "https://company.com";

View File

@@ -1,53 +1,48 @@
import { WS_SCRCPY_URL } from "$lib/core/constants";
import type { Device } from "@pkg/logic/domains/device/data";
import { getDeviceByIdSQ } from "./device.remote"; import { getDeviceByIdSQ } from "./device.remote";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
type Device = { type DeviceForUI = Omit<Device, "createdAt" | "updatedAt"> & {
id: number;
title: string;
version: string;
status: string;
isActive: boolean;
inUse: boolean;
containerId: string;
host: string;
wsPort: string;
createdAt: Date | string; createdAt: Date | string;
updatedAt: Date | string; updatedAt: Date | string;
}; };
function normalizeViewerUrl(host: string, wsPort: string): string | null { /** Port the scrcpy server listens on inside the Android container. */
const SCRCPY_SERVER_PORT = 8886;
/**
* Builds the ws-scrcpy hash-based stream URL for a device.
*
* Example output:
* https://iotam-ws-scrcpy.snapyra.com/#!action=stream&udid=172.17.0.1:5555
* &player=mse&ws=wss://iotam-ws-scrcpy.snapyra.com/?action=proxy-adb
* &remote=tcp:8886&udid=172.17.0.1:5555
*/
function buildStreamUrl(host: string, wsPort: string): string | null {
const trimmedHost = host.trim(); const trimmedHost = host.trim();
if (!trimmedHost) return null; if (!trimmedHost) return null;
if (trimmedHost.startsWith("http://") || trimmedHost.startsWith("https://")) { const udid = `${trimmedHost}:${wsPort}`;
try { const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
const url = new URL(trimmedHost);
if (!url.port) url.port = wsPort;
return url.toString();
} catch {
return trimmedHost;
}
}
const hostWithProtocol = `https://${trimmedHost}`; const wsParam =
`${baseWss}/?action=proxy-adb&remote=tcp:${SCRCPY_SERVER_PORT}&udid=${encodeURIComponent(udid)}`;
try { const hash =
const url = new URL(hostWithProtocol); `#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`;
if (!url.port) url.port = wsPort;
return url.toString(); return `${WS_SCRCPY_URL}/${hash}`;
} catch {
return null;
}
} }
class DeviceDetailsViewModel { class DeviceDetailsViewModel {
device = $state<Device | null>(null); device = $state<DeviceForUI | null>(null);
loading = $state(false); loading = $state(false);
currentId = $state<number | null>(null); currentId = $state<number | null>(null);
get streamUrl(): string | null { get streamUrl(): string | null {
if (!this.device) return null; if (!this.device) return null;
return normalizeViewerUrl(this.device.host, this.device.wsPort); return buildStreamUrl(this.device.host, this.device.wsPort);
} }
async fetchDevice(id: number) { async fetchDevice(id: number) {
@@ -64,7 +59,7 @@ class DeviceDetailsViewModel {
return; return;
} }
this.device = result.data as Device; this.device = result.data as DeviceForUI;
} catch (error) { } catch (error) {
this.device = null; this.device = null;
toast.error("Failed to fetch device", { toast.error("Failed to fetch device", {

View File

@@ -3,8 +3,7 @@
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Switch } from "$lib/components/ui/switch"; import { Switch } from "$lib/components/ui/switch";
import type { DeviceStatusValue } from "@pkg/logic/domains/device/data";
type DeviceStatus = "online" | "offline" | "busy" | "error";
let { let {
fieldPrefix = "device-form", fieldPrefix = "device-form",
@@ -15,7 +14,7 @@
wsPort = $bindable(""), wsPort = $bindable(""),
isActive = $bindable(false), isActive = $bindable(false),
inUse = $bindable(false), inUse = $bindable(false),
status = $bindable("offline" as DeviceStatus), status = $bindable("offline" as DeviceStatusValue),
submitLabel = "Save", submitLabel = "Save",
submitting = false, submitting = false,
showAdvanced = false, showAdvanced = false,
@@ -30,7 +29,7 @@
wsPort?: string; wsPort?: string;
isActive?: boolean; isActive?: boolean;
inUse?: boolean; inUse?: boolean;
status?: DeviceStatus; status?: DeviceStatusValue;
submitLabel?: string; submitLabel?: string;
submitting?: boolean; submitting?: boolean;
showAdvanced?: boolean; showAdvanced?: boolean;

View File

@@ -1,5 +1,9 @@
import { getDeviceController } from "@pkg/logic/domains/device/controller"; import { getDeviceController } from "@pkg/logic/domains/device/controller";
import { createDeviceSchema, updateDeviceSchema } from "@pkg/logic/domains/device/data"; import {
createDeviceSchema,
deviceStatusSchema,
updateDeviceSchema,
} from "@pkg/logic/domains/device/data";
import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils"; import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server"; import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot"; import * as v from "valibot";
@@ -73,14 +77,14 @@ export const deleteDeviceSC = command(
export const setDeviceStatusSC = command( export const setDeviceStatusSC = command(
v.object({ v.object({
id: v.number(), id: v.number(),
status: v.picklist(["online", "offline", "busy", "error"]), status: deviceStatusSchema,
}), }),
async (payload) => { async (payload) => {
const event = getRequestEvent(); const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx); if (!fctx.userId) return unauthorized(fctx);
const res = await dc.setStatus(fctx, payload.id, payload.status as any); const res = await dc.setStatus(fctx, payload.id, payload.status);
return res.isOk() return res.isOk()
? { data: res.value, error: null } ? { data: res.value, error: null }
: { data: null, error: res.error }; : { data: null, error: res.error };

View File

@@ -1,3 +1,9 @@
import {
type CreateDevice,
type Device,
type DeviceStatusValue,
type UpdateDevice,
} from "@pkg/logic/domains/device/data";
import { import {
listDevicesSQ, listDevicesSQ,
createDeviceSC, createDeviceSC,
@@ -7,34 +13,6 @@ import {
} from "./device.remote"; } from "./device.remote";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
type Device = {
id: number;
title: string;
version: string;
status: string;
isActive: boolean;
inUse: boolean;
containerId: string;
host: string;
wsPort: string;
createdAt: Date;
updatedAt: Date;
};
type CreateDeviceInput = {
title: string;
version: string;
host: string;
containerId: string;
wsPort: string;
isActive?: boolean;
};
type UpdateDeviceInput = CreateDeviceInput & {
status: "online" | "offline" | "busy" | "error";
inUse: boolean;
};
class DeviceViewModel { class DeviceViewModel {
devices = $state<Device[]>([]); devices = $state<Device[]>([]);
loading = $state(false); loading = $state(false);
@@ -71,7 +49,7 @@ class DeviceViewModel {
} }
} }
async createDevice(data: CreateDeviceInput): Promise<boolean> { async createDevice(data: CreateDevice): Promise<boolean> {
this.creating = true; this.creating = true;
try { try {
const result = await createDeviceSC(data); const result = await createDeviceSC(data);
@@ -132,7 +110,7 @@ class DeviceViewModel {
} }
} }
async updateDevice(id: number, data: UpdateDeviceInput): Promise<boolean> { async updateDevice(id: number, data: UpdateDevice): Promise<boolean> {
this.updating = true; this.updating = true;
this.editingId = id; this.editingId = id;
try { try {
@@ -169,11 +147,11 @@ class DeviceViewModel {
} }
} }
async setStatus(id: number, status: string) { async setStatus(id: number, status: DeviceStatusValue) {
try { try {
const result = await setDeviceStatusSC({ const result = await setDeviceStatusSC({
id, id,
status: status as any, status,
}); });
if (result?.error) { if (result?.error) {
toast.error( toast.error(

View File

@@ -1,3 +1,6 @@
import type { Device } from "@pkg/logic/domains/device/data";
import type { CreateLink, LinkWithDevice } from "@pkg/logic/domains/link/data";
import type { SupportedApp } from "@pkg/logic/domains/supported-app/data";
import { import {
listLinksSQ, listLinksSQ,
createLinkSC, createLinkSC,
@@ -6,32 +9,17 @@ import {
assignDeviceSC, assignDeviceSC,
} from "./link.remote"; } from "./link.remote";
import { listDevicesSQ } from "../device/device.remote"; import { listDevicesSQ } from "../device/device.remote";
import { listSupportedAppsSQ } from "../supported-app/supported-app.remote";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
type Link = { type DeviceOption = Pick<Device, "id" | "title" | "host" | "status" | "inUse">;
id: number; type SupportedAppOption = Pick<SupportedApp, "id" | "title" | "packageName">;
token: string; type CreateLinkInput = Omit<CreateLink, "token">;
status: string;
appName: string;
appPackage: string;
linkedDeviceId: number | null;
expiresAt: Date | null;
lastAccessedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type DeviceOption = {
id: number;
title: string;
host: string;
status: string;
inUse: boolean;
};
class LinkViewModel { class LinkViewModel {
links = $state<Link[]>([]); links = $state<LinkWithDevice[]>([]);
availableDevices = $state<DeviceOption[]>([]); availableDevices = $state<DeviceOption[]>([]);
availableSupportedApps = $state<SupportedAppOption[]>([]);
loading = $state(false); loading = $state(false);
creating = $state(false); creating = $state(false);
deletingId = $state<number | null>(null); deletingId = $state<number | null>(null);
@@ -52,7 +40,7 @@ class LinkViewModel {
); );
return; return;
} }
this.links = result.data as Link[]; this.links = result.data as LinkWithDevice[];
} catch (error) { } catch (error) {
toast.error("Failed to fetch links", { toast.error("Failed to fetch links", {
description: description:
@@ -69,7 +57,7 @@ class LinkViewModel {
try { try {
const result = await listDevicesSQ(); const result = await listDevicesSQ();
if (result?.data) { if (result?.data) {
this.availableDevices = (result.data as any[]).map((d) => ({ this.availableDevices = (result.data as Device[]).map((d) => ({
id: d.id, id: d.id,
title: d.title, title: d.title,
host: d.host, host: d.host,
@@ -78,16 +66,22 @@ class LinkViewModel {
})); }));
} }
} catch { } catch {
// Non-critical select will just be empty // Non-critical - select will just be empty
} }
} }
async createLink(data: { async fetchSupportedAppsForSelect() {
linkedDeviceId: number; try {
appName: string; const result = await listSupportedAppsSQ();
appPackage: string; if (result?.data) {
expiresAt?: Date | null; this.availableSupportedApps = result.data as SupportedAppOption[];
}): Promise<boolean> { }
} catch {
// Non-critical - select will just be empty
}
}
async createLink(data: CreateLinkInput): Promise<boolean> {
this.creating = true; this.creating = true;
try { try {
const result = await createLinkSC(data); const result = await createLinkSC(data);
@@ -100,9 +94,7 @@ class LinkViewModel {
} }
toast.success("Link created"); toast.success("Link created");
this.showCreateDialog = false; this.showCreateDialog = false;
if (result.data) { await this.fetchLinks();
this.links = [...this.links, result.data as Link];
}
return true; return true;
} catch (error) { } catch (error) {
toast.error("Failed to create link", { toast.error("Failed to create link", {
@@ -197,6 +189,11 @@ class LinkViewModel {
const device = this.availableDevices.find((d) => d.id === deviceId); const device = this.availableDevices.find((d) => d.id === deviceId);
return device ? device.title : `Device #${deviceId}`; return device ? device.title : `Device #${deviceId}`;
} }
getSupportedAppName(supportedAppId: number): string {
const app = this.availableSupportedApps.find((d) => d.id === supportedAppId);
return app ? app.title : `App #${supportedAppId}`;
}
} }
export const linkVM = new LinkViewModel(); export const linkVM = new LinkViewModel();

View File

@@ -0,0 +1,63 @@
import { getSupportedAppController } from "@pkg/logic/domains/supported-app/controller";
import {
createSupportedAppSchema,
updateSupportedAppSchema,
} from "@pkg/logic/domains/supported-app/data";
import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const sac = getSupportedAppController();
export const listSupportedAppsSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await sac.list(fctx);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const createSupportedAppSC = command(
createSupportedAppSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await sac.create(fctx, payload);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const updateSupportedAppSC = command(
v.object({ id: v.number(), data: updateSupportedAppSchema }),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await sac.update(fctx, payload.id, payload.data);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const deleteSupportedAppSC = command(
v.object({ id: v.number() }),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await sac.delete(fctx, payload.id);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);

View File

@@ -0,0 +1,152 @@
import type {
CreateSupportedApp,
SupportedApp,
UpdateSupportedApp,
} from "@pkg/logic/domains/supported-app/data";
import {
createSupportedAppSC,
deleteSupportedAppSC,
listSupportedAppsSQ,
updateSupportedAppSC,
} from "./supported-app.remote";
import { toast } from "svelte-sonner";
class SupportedAppViewModel {
supportedApps = $state<SupportedApp[]>([]);
loading = $state(false);
creating = $state(false);
updating = $state(false);
deletingId = $state<number | null>(null);
showCreateDialog = $state(false);
async fetchSupportedApps() {
this.loading = true;
try {
const result = await listSupportedAppsSQ();
if (result?.error || !result?.data) {
toast.error(
result?.error?.message || "Failed to fetch supported apps",
{
description:
result?.error?.description || "Please try again",
},
);
return;
}
this.supportedApps = result.data as SupportedApp[];
} catch (error) {
toast.error("Failed to fetch supported apps", {
description:
error instanceof Error
? error.message
: "Please try again",
});
} finally {
this.loading = false;
}
}
async createSupportedApp(data: CreateSupportedApp): Promise<boolean> {
this.creating = true;
try {
const result = await createSupportedAppSC(data);
if (result?.error) {
toast.error(
result.error.message || "Failed to create supported app",
{
description:
result.error.description || "Please try again",
},
);
return false;
}
toast.success("Supported app created");
this.showCreateDialog = false;
if (result.data) {
this.supportedApps = [
...this.supportedApps,
result.data as SupportedApp,
];
}
return true;
} catch (error) {
toast.error("Failed to create supported app", {
description:
error instanceof Error
? error.message
: "Please try again",
});
return false;
} finally {
this.creating = false;
}
}
async updateSupportedApp(
id: number,
data: UpdateSupportedApp,
): Promise<boolean> {
this.updating = true;
try {
const result = await updateSupportedAppSC({ id, data });
if (result?.error) {
toast.error(
result.error.message || "Failed to update supported app",
{
description:
result.error.description || "Please try again",
},
);
return false;
}
toast.success("Supported app updated");
if (result.data) {
const updated = result.data as SupportedApp;
this.supportedApps = this.supportedApps.map((app) =>
app.id === id ? updated : app,
);
}
return true;
} catch (error) {
toast.error("Failed to update supported app", {
description:
error instanceof Error
? error.message
: "Please try again",
});
return false;
} finally {
this.updating = false;
}
}
async deleteSupportedApp(id: number) {
this.deletingId = id;
try {
const result = await deleteSupportedAppSC({ id });
if (result?.error) {
toast.error(
result.error.message || "Failed to delete supported app",
{
description:
result.error.description || "Please try again",
},
);
return;
}
toast.success("Supported app deleted");
this.supportedApps = this.supportedApps.filter((app) => app.id !== id);
} catch (error) {
toast.error("Failed to delete supported app", {
description:
error instanceof Error
? error.message
: "Please try again",
});
} finally {
this.deletingId = null;
}
}
}
export const supportedAppVM = new SupportedAppViewModel();

View File

@@ -9,7 +9,7 @@
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import * as Table from "$lib/components/ui/table"; import * as Table from "$lib/components/ui/table";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte"; import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree, SUPPORTED_APPS } from "$lib/core/constants"; import { mainNavTree } from "$lib/core/constants";
import { linkVM } from "$lib/domains/link/link.vm.svelte"; import { linkVM } from "$lib/domains/link/link.vm.svelte";
import { breadcrumbs } from "$lib/global.stores"; import { breadcrumbs } from "$lib/global.stores";
import LinkIcon from "@lucide/svelte/icons/link"; import LinkIcon from "@lucide/svelte/icons/link";
@@ -24,35 +24,26 @@
breadcrumbs.set([mainNavTree[0], mainNavTree[1]]); breadcrumbs.set([mainNavTree[0], mainNavTree[1]]);
let selectedDeviceId = $state(""); let selectedDeviceId = $state("");
let selectedAppPackage = $state(""); let selectedSupportedAppId = $state("");
let expiresAt = $state(""); let expiresAt = $state("");
onMount(async () => { onMount(async () => {
await linkVM.fetchLinks(); await linkVM.fetchLinks();
await linkVM.fetchDevicesForSelect(); await linkVM.fetchDevicesForSelect();
await linkVM.fetchSupportedAppsForSelect();
}); });
function resetForm() { function resetForm() {
selectedDeviceId = ""; selectedDeviceId = "";
selectedAppPackage = ""; selectedSupportedAppId = "";
expiresAt = ""; expiresAt = "";
} }
function getSupportedApp(packageName: string) {
return SUPPORTED_APPS.find((app) => app.packageName === packageName);
}
async function handleCreate(e: Event) { async function handleCreate(e: Event) {
e.preventDefault(); e.preventDefault();
const selectedApp = getSupportedApp(selectedAppPackage);
if (!selectedApp) {
toast.error("Select a supported app");
return;
}
const success = await linkVM.createLink({ const success = await linkVM.createLink({
linkedDeviceId: Number(selectedDeviceId), linkedDeviceId: Number(selectedDeviceId),
appName: selectedApp.title, supportedAppId: Number(selectedSupportedAppId),
appPackage: selectedApp.packageName,
expiresAt: expiresAt ? new Date(expiresAt) : null, expiresAt: expiresAt ? new Date(expiresAt) : null,
}); });
if (success) resetForm(); if (success) resetForm();
@@ -121,6 +112,7 @@
onclick={() => { onclick={() => {
linkVM.showCreateDialog = true; linkVM.showCreateDialog = true;
linkVM.fetchDevicesForSelect(); linkVM.fetchDevicesForSelect();
linkVM.fetchSupportedAppsForSelect();
}} }}
> >
<Icon icon={Plus} cls="h-4 w-4 mr-2" /> <Icon icon={Plus} cls="h-4 w-4 mr-2" />
@@ -176,11 +168,11 @@
</div> </div>
<div> <div>
<p class="text-muted-foreground">App</p> <p class="text-muted-foreground">App</p>
<p>{link.appName}</p> <p>
<p {link.supportedApp?.title ||
class="text-muted-foreground break-all font-mono text-[11px]" linkVM.getSupportedAppName(
> link.supportedAppId,
{link.appPackage} )}
</p> </p>
</div> </div>
<div> <div>
@@ -301,12 +293,10 @@
<Table.Cell> <Table.Cell>
<div class="space-y-0.5"> <div class="space-y-0.5">
<p class="text-sm font-medium"> <p class="text-sm font-medium">
{link.appName} {link.supportedApp?.title ||
</p> linkVM.getSupportedAppName(
<p link.supportedAppId,
class="text-muted-foreground font-mono text-[11px]" )}
>
{link.appPackage}
</p> </p>
</div> </div>
</Table.Cell> </Table.Cell>
@@ -430,20 +420,19 @@
<Label for="supportedApp">Supported App</Label> <Label for="supportedApp">Supported App</Label>
<select <select
id="supportedApp" id="supportedApp"
bind:value={selectedAppPackage} bind:value={selectedSupportedAppId}
required required
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1" class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1"
> >
<option value="" disabled>Select a supported app</option> <option value="" disabled>Select a supported app</option>
{#each SUPPORTED_APPS as app} {#each linkVM.availableSupportedApps as app}
<option value={app.packageName}> <option value={String(app.id)}>
{app.title}{app.packageName} {app.title}
</option> </option>
{/each} {/each}
</select> </select>
<p class="text-muted-foreground text-xs"> <p class="text-muted-foreground text-xs">
Links can only target apps from the current supported app Supported apps are managed from the dedicated catalogue page.
list.
</p> </p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">

View File

@@ -0,0 +1,358 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Table from "$lib/components/ui/table";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree } from "$lib/core/constants";
import { supportedAppVM } from "$lib/domains/supported-app/supported-app.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import AppWindow from "@lucide/svelte/icons/app-window";
import Pencil from "@lucide/svelte/icons/pencil";
import Plus from "@lucide/svelte/icons/plus";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import Trash2 from "@lucide/svelte/icons/trash-2";
import { onMount } from "svelte";
breadcrumbs.set([mainNavTree[0], mainNavTree[2]]);
let title = $state("");
let packageName = $state("");
let editId = $state<number | null>(null);
let editTitle = $state("");
let editPackageName = $state("");
let showEditDialog = $state(false);
onMount(async () => {
await supportedAppVM.fetchSupportedApps();
});
function resetCreateForm() {
title = "";
packageName = "";
}
function resetEditForm() {
editId = null;
editTitle = "";
editPackageName = "";
}
async function handleCreate(e: Event) {
e.preventDefault();
const success = await supportedAppVM.createSupportedApp({
title,
packageName,
});
if (success) resetCreateForm();
}
function openEditDialog(app: (typeof supportedAppVM.supportedApps)[number]) {
editId = app.id;
editTitle = app.title;
editPackageName = app.packageName;
showEditDialog = true;
}
async function handleUpdate(e: Event) {
e.preventDefault();
if (!editId) return;
const success = await supportedAppVM.updateSupportedApp(editId, {
title: editTitle,
packageName: editPackageName,
});
if (success) {
showEditDialog = false;
resetEditForm();
}
}
function formatDate(value: Date | string): string {
return new Date(value).toLocaleString();
}
$effect(() => {
if (showEditDialog) return;
resetEditForm();
});
</script>
<MaxWidthWrapper cls="space-y-4">
<Card.Root>
<Card.Header>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-2">
<Icon icon={AppWindow} cls="h-5 w-5 text-primary" />
<Card.Title>Supported Apps</Card.Title>
<span class="text-muted-foreground text-xs">
{supportedAppVM.supportedApps.length} total
</span>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => void supportedAppVM.fetchSupportedApps()}
disabled={supportedAppVM.loading}
>
<Icon
icon={RefreshCw}
cls={`mr-2 h-4 w-4 ${supportedAppVM.loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
size="sm"
onclick={() => (supportedAppVM.showCreateDialog = true)}
>
<Icon icon={Plus} cls="mr-2 h-4 w-4" />
Add Supported App
</Button>
</div>
</div>
</Card.Header>
<Card.Content>
{#if !supportedAppVM.loading && supportedAppVM.supportedApps.length === 0}
<div class="py-10 text-center text-sm text-muted-foreground">
No supported apps configured yet.
</div>
{:else}
<div class="space-y-3 md:hidden">
{#each supportedAppVM.supportedApps as app (app.id)}
<div class="rounded-lg border bg-background p-3">
<div class="space-y-1">
<p class="text-sm font-medium">{app.title}</p>
<p
class="text-muted-foreground break-all font-mono text-xs"
>
{app.packageName}
</p>
</div>
<div class="text-muted-foreground mt-3 text-xs">
Updated {formatDate(app.updatedAt)}
</div>
<div class="mt-3 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={() => openEditDialog(app)}
>
<Icon icon={Pencil} cls="h-4 w-4" />
</Button>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={supportedAppVM.deletingId ===
app.id}
>
<Icon icon={Trash2} cls="h-4 w-4" />
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete supported app?
</AlertDialog.Title>
<AlertDialog.Description>
This removes "{app.title}" from
the supported app catalogue.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() =>
supportedAppVM.deleteSupportedApp(
app.id,
)}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</div>
{/each}
</div>
<div class="hidden md:block">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Package Name</Table.Head>
<Table.Head>Updated</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each supportedAppVM.supportedApps as app (app.id)}
<Table.Row>
<Table.Cell class="font-medium">
{app.title}
</Table.Cell>
<Table.Cell class="font-mono text-xs">
{app.packageName}
</Table.Cell>
<Table.Cell class="text-xs">
{formatDate(app.updatedAt)}
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onclick={() =>
openEditDialog(app)}
>
<Icon
icon={Pencil}
cls="h-4 w-4"
/>
Edit
</Button>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={supportedAppVM.deletingId ===
app.id}
>
<Icon
icon={Trash2}
cls="h-4 w-4"
/>
Delete
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete supported
app?
</AlertDialog.Title>
<AlertDialog.Description>
This removes
"{app.title}" from
the supported app
catalogue.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() =>
supportedAppVM.deleteSupportedApp(
app.id,
)}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</MaxWidthWrapper>
<Dialog.Root bind:open={supportedAppVM.showCreateDialog}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Add Supported App</Dialog.Title>
<Dialog.Description>
Register an app that links are allowed to lease.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleCreate} class="space-y-4">
<div class="space-y-2">
<Label for="title">Title</Label>
<Input id="title" bind:value={title} required />
</div>
<div class="space-y-2">
<Label for="packageName">Package Name</Label>
<Input
id="packageName"
bind:value={packageName}
placeholder="com.example.app"
required
/>
</div>
<Dialog.Footer>
<Button
variant="outline"
type="button"
onclick={() => (supportedAppVM.showCreateDialog = false)}
>
Cancel
</Button>
<Button type="submit" disabled={supportedAppVM.creating}>
{supportedAppVM.creating ? "Creating..." : "Create"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={showEditDialog}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Edit Supported App</Dialog.Title>
<Dialog.Description>
Update the supported app record used by the links flow.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleUpdate} class="space-y-4">
<div class="space-y-2">
<Label for="edit-title">Title</Label>
<Input id="edit-title" bind:value={editTitle} required />
</div>
<div class="space-y-2">
<Label for="edit-packageName">Package Name</Label>
<Input
id="edit-packageName"
bind:value={editPackageName}
placeholder="com.example.app"
required
/>
</div>
<Dialog.Footer>
<Button
variant="outline"
type="button"
onclick={() => {
showEditDialog = false;
resetEditForm();
}}
>
Cancel
</Button>
<Button type="submit" disabled={supportedAppVM.updating}>
{supportedAppVM.updating ? "Saving..." : "Save Changes"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -90,3 +90,23 @@ Update rule:
- Cleaned up unused `Smartphone` import from constants.ts - Cleaned up unused `Smartphone` import from constants.ts
- Updated detail page breadcrumbs to go Dashboard > Device instead of Dashboard > Devices > Device - Updated detail page breadcrumbs to go Dashboard > Device instead of Dashboard > Devices > Device
- Updated `goto` calls to navigate to `/dashboard/:id` instead of `/devices/:id` - Updated `goto` calls to navigate to `/dashboard/:id` instead of `/devices/:id`
### 12 — Supported Apps Catalogue
- Added a normalized `supported_app` schema and matching logic domain for supported-app CRUD
- Added an admin `/supported-apps` page and sidebar entry for managing the app catalogue
- Refactored links to reference `supportedAppId` and load supported app labels from the catalogue instead of freeform app fields/constants
### 13 — Fixed ws-scrcpy Stream URL
- Added `WS_SCRCPY_URL` constant in `constants.ts` for the static public ws-scrcpy domain
- Replaced broken `normalizeViewerUrl` (which wrongly jammed ADB port into the public domain) with `buildStreamUrl` in `device-details.vm.svelte.ts`
- New builder constructs the correct hash-based ws-scrcpy stream URL: `#!action=stream&udid={host}:{wsPort}&player=mse&ws=wss://.../?action=proxy-adb&remote=tcp:8886&udid={host}:{wsPort}`
- Device `host` and `wsPort` are now correctly treated as internal ADB address (e.g. `172.17.0.1:5555`), not the public domain
- Scrcpy server port (8886) hardcoded as constant since it's static per the ws-scrcpy architecture
### 14 — Frontend Domain Type Source-of-Truth Refactor
- Refactored `apps/main/src/lib/domains/{device,link,supported-app}` to remove local duplicate data type declarations and import canonical types from `@pkg/logic/domains/*/data`
- Updated device status validation in `device.remote.ts` to reuse `deviceStatusSchema` from logic instead of a duplicated local picklist
- Kept only derived UI helper types (`Pick`/`Omit`) where needed for presentation and transport-shape compatibility

View File

@@ -0,0 +1,13 @@
CREATE TABLE "supported_app" (
"id" serial PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"package_name" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "supported_app_package_name_unique" UNIQUE("package_name")
);
--> statement-breakpoint
ALTER TABLE "link" ADD COLUMN "supported_app_id" integer NOT NULL;--> statement-breakpoint
ALTER TABLE "link" ADD CONSTRAINT "link_supported_app_id_supported_app_id_fk" FOREIGN KEY ("supported_app_id") REFERENCES "public"."supported_app"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "link" DROP COLUMN "app_name";--> statement-breakpoint
ALTER TABLE "link" DROP COLUMN "app_package";

View File

@@ -0,0 +1,997 @@
{
"id": "f9535ed5-4fb8-4f58-b741-712a5c7356a1",
"prevId": "a633b0b6-32a7-4f7f-8b17-4264fe54ca57",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.two_factor": {
"name": "two_factor",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"secret": {
"name": "secret",
"type": "text",
"primaryKey": false,
"notNull": true
},
"backup_codes": {
"name": "backup_codes",
"type": "json",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"two_factor_user_id_user_id_fk": {
"name": "two_factor_user_id_user_id_fk",
"tableFrom": "two_factor",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.twofa_sessions": {
"name": "twofa_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"verification_token": {
"name": "verification_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"code_used": {
"name": "code_used",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_attempts": {
"name": "max_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 5
},
"verified_at": {
"name": "verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "''"
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "''"
}
},
"indexes": {},
"foreignKeys": {
"twofa_sessions_user_id_user_id_fk": {
"name": "twofa_sessions_user_id_user_id_fk",
"tableFrom": "twofa_sessions",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"twofa_sessions_verification_token_unique": {
"name": "twofa_sessions_verification_token_unique",
"nullsNotDistinct": false,
"columns": [
"verification_token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_username": {
"name": "display_username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"onboarding_done": {
"name": "onboarding_done",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"last2_fa_verified_at": {
"name": "last2_fa_verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.device": {
"name": "device",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'offline'"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"in_use": {
"name": "in_use",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"container_id": {
"name": "container_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ws_port": {
"name": "ws_port",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notifications": {
"name": "notifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"priority": {
"name": "priority",
"type": "varchar(12)",
"primaryKey": false,
"notNull": true,
"default": "'normal'"
},
"type": {
"name": "type",
"type": "varchar(12)",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_archived": {
"name": "is_archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"action_url": {
"name": "action_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"action_type": {
"name": "action_type",
"type": "varchar(16)",
"primaryKey": false,
"notNull": false
},
"action_data": {
"name": "action_data",
"type": "json",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sent_at": {
"name": "sent_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"read_at": {
"name": "read_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"notifications_user_id_user_id_fk": {
"name": "notifications_user_id_user_id_fk",
"tableFrom": "notifications",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.link": {
"name": "link",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"linked_device_id": {
"name": "linked_device_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"supported_app_id": {
"name": "supported_app_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_accessed_at": {
"name": "last_accessed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"link_linked_device_id_device_id_fk": {
"name": "link_linked_device_id_device_id_fk",
"tableFrom": "link",
"tableTo": "device",
"columnsFrom": [
"linked_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"link_supported_app_id_supported_app_id_fk": {
"name": "link_supported_app_id_supported_app_id_fk",
"tableFrom": "link",
"tableTo": "supported_app",
"columnsFrom": [
"supported_app_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"link_token_unique": {
"name": "link_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.supported_app": {
"name": "supported_app",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"package_name": {
"name": "package_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"supported_app_package_name_unique": {
"name": "supported_app_package_name_unique",
"nullsNotDistinct": false,
"columns": [
"package_name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.task": {
"name": "task",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"payload": {
"name": "payload",
"type": "json",
"primaryKey": false,
"notNull": false
},
"result": {
"name": "result",
"type": "json",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "json",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"task_user_id_user_id_fk": {
"name": "task_user_id_user_id_fk",
"tableFrom": "task",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1774703478082, "when": 1774703478082,
"tag": "0002_remarkable_charles_xavier", "tag": "0002_remarkable_charles_xavier",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1774705734646,
"tag": "0003_workable_runaways",
"breakpoints": true
} }
] ]
} }

View File

@@ -3,4 +3,5 @@ export * from "./better.auth.schema";
export * from "./device.schema"; export * from "./device.schema";
export * from "./general.schema"; export * from "./general.schema";
export * from "./link.schema"; export * from "./link.schema";
export * from "./supported-app.schema";
export * from "./task.schema"; export * from "./task.schema";

View File

@@ -6,6 +6,7 @@ import {
timestamp, timestamp,
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { supportedApp } from "./supported-app.schema";
import { device } from "./device.schema"; import { device } from "./device.schema";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
@@ -14,12 +15,14 @@ export const link = pgTable("link", {
token: text("token").notNull().unique(), token: text("token").notNull().unique(),
status: varchar("status", { length: 16 }).notNull().default("active"), // "active" | "inactive" | "expired" | "revoked" status: varchar("status", { length: 16 }).notNull().default("active"), // "active" | "inactive" | "expired" | "revoked"
appName: text("app_name").notNull(),
appPackage: text("app_package").notNull(),
linkedDeviceId: integer("linked_device_id").references(() => device.id, { linkedDeviceId: integer("linked_device_id").references(() => device.id, {
onDelete: "set null", onDelete: "set null",
}), }),
supportedAppId: integer("supported_app_id")
.notNull()
.references(() => supportedApp.id, {
onDelete: "restrict",
}),
expiresAt: timestamp("expires_at"), expiresAt: timestamp("expires_at"),
lastAccessedAt: timestamp("last_accessed_at"), lastAccessedAt: timestamp("last_accessed_at"),
@@ -32,4 +35,8 @@ export const linkRelations = relations(link, ({ one }) => ({
fields: [link.linkedDeviceId], fields: [link.linkedDeviceId],
references: [device.id], references: [device.id],
}), }),
supportedApp: one(supportedApp, {
fields: [link.supportedAppId],
references: [supportedApp.id],
}),
})); }));

View File

@@ -0,0 +1,9 @@
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const supportedApp = pgTable("supported_app", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
packageName: text("package_name").notNull().unique(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});

View File

@@ -3,18 +3,24 @@ import { nanoid } from "nanoid";
import { db } from "@pkg/db"; import { db } from "@pkg/db";
import { type Err } from "@pkg/result"; import { type Err } from "@pkg/result";
import { FlowExecCtx } from "@core/flow.execution.context"; import { FlowExecCtx } from "@core/flow.execution.context";
import { CreateLink, Link, LinkStatus, LinkWithDevice, UpdateLink } from "./data"; import {
CreateLink,
Link,
LinkStatus,
LinkWithDevice,
UpdateLink,
} from "./data";
import { LinkRepository } from "./repository"; import { LinkRepository } from "./repository";
import { linkErrors } from "./errors"; import { linkErrors } from "./errors";
export class LinkController { export class LinkController {
constructor(private repo: LinkRepository) {} constructor(private repo: LinkRepository) {}
list(fctx: FlowExecCtx): ResultAsync<Link[], Err> { list(fctx: FlowExecCtx): ResultAsync<LinkWithDevice[], Err> {
return this.repo.list(fctx); return this.repo.list(fctx);
} }
getById(fctx: FlowExecCtx, id: number): ResultAsync<Link, Err> { getById(fctx: FlowExecCtx, id: number): ResultAsync<LinkWithDevice, Err> {
return this.repo.getById(fctx, id); return this.repo.getById(fctx, id);
} }

View File

@@ -1,5 +1,6 @@
import * as v from "valibot"; import * as v from "valibot";
import { deviceSchema } from "@domains/device/data"; import { deviceSchema } from "@domains/device/data";
import { supportedAppSchema } from "@domains/supported-app/data";
export enum LinkStatus { export enum LinkStatus {
ACTIVE = "active", ACTIVE = "active",
@@ -15,9 +16,8 @@ export const linkSchema = v.object({
id: v.number(), id: v.number(),
token: v.string(), token: v.string(),
status: linkStatusSchema, status: linkStatusSchema,
appName: v.string(),
appPackage: v.string(),
linkedDeviceId: v.nullable(v.number()), linkedDeviceId: v.nullable(v.number()),
supportedAppId: v.number(),
expiresAt: v.nullable(v.date()), expiresAt: v.nullable(v.date()),
lastAccessedAt: v.nullable(v.date()), lastAccessedAt: v.nullable(v.date()),
createdAt: v.date(), createdAt: v.date(),
@@ -28,14 +28,14 @@ export type Link = v.InferOutput<typeof linkSchema>;
export const linkWithDeviceSchema = v.object({ export const linkWithDeviceSchema = v.object({
...linkSchema.entries, ...linkSchema.entries,
device: v.nullable(deviceSchema), device: v.nullable(deviceSchema),
supportedApp: v.nullable(supportedAppSchema),
}); });
export type LinkWithDevice = v.InferOutput<typeof linkWithDeviceSchema>; export type LinkWithDevice = v.InferOutput<typeof linkWithDeviceSchema>;
export const createLinkSchema = v.object({ export const createLinkSchema = v.object({
token: v.pipe(v.string(), v.minLength(1)), token: v.pipe(v.string(), v.minLength(1)),
appName: v.pipe(v.string(), v.minLength(1)),
appPackage: v.pipe(v.string(), v.minLength(1)),
linkedDeviceId: v.number(), linkedDeviceId: v.number(),
supportedAppId: v.number(),
expiresAt: v.optional(v.nullable(v.date())), expiresAt: v.optional(v.nullable(v.date())),
}); });
export type CreateLink = v.InferOutput<typeof createLinkSchema>; export type CreateLink = v.InferOutput<typeof createLinkSchema>;
@@ -43,9 +43,8 @@ export type CreateLink = v.InferOutput<typeof createLinkSchema>;
export const updateLinkSchema = v.partial( export const updateLinkSchema = v.partial(
v.object({ v.object({
status: linkStatusSchema, status: linkStatusSchema,
appName: v.string(),
appPackage: v.string(),
linkedDeviceId: v.nullable(v.number()), linkedDeviceId: v.nullable(v.number()),
supportedAppId: v.number(),
expiresAt: v.nullable(v.date()), expiresAt: v.nullable(v.date()),
lastAccessedAt: v.nullable(v.date()), lastAccessedAt: v.nullable(v.date()),
}), }),

View File

@@ -1,6 +1,6 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow"; import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context"; import { FlowExecCtx } from "@core/flow.execution.context";
import { Database, asc, eq } from "@pkg/db"; import { Database, eq } from "@pkg/db";
import { link } from "@pkg/db/schema"; import { link } from "@pkg/db/schema";
import { type Err } from "@pkg/result"; import { type Err } from "@pkg/result";
import { logger } from "@pkg/logger"; import { logger } from "@pkg/logger";
@@ -11,29 +11,41 @@ import { linkErrors } from "./errors";
export class LinkRepository { export class LinkRepository {
constructor(private db: Database) {} constructor(private db: Database) {}
list(fctx: FlowExecCtx): ResultAsync<Link[], Err> { list(fctx: FlowExecCtx): ResultAsync<LinkWithDevice[], Err> {
return traceResultAsync({ return traceResultAsync({
name: "link.list", name: "link.list",
fctx, fctx,
fn: () => fn: () =>
ResultAsync.fromPromise( ResultAsync.fromPromise(
this.db.select().from(link).orderBy(asc(link.createdAt)), this.db.query.link.findMany({
orderBy: (link, { asc }) => [asc(link.createdAt)],
with: {
device: true,
supportedApp: true,
},
}),
(e) => (e) =>
linkErrors.listFailed( linkErrors.listFailed(
fctx, fctx,
e instanceof Error ? e.message : String(e), e instanceof Error ? e.message : String(e),
), ),
).map((rows) => rows as Link[]), ).map((rows) => rows as LinkWithDevice[]),
}); });
} }
getById(fctx: FlowExecCtx, id: number): ResultAsync<Link, Err> { getById(fctx: FlowExecCtx, id: number): ResultAsync<LinkWithDevice, Err> {
return traceResultAsync({ return traceResultAsync({
name: "link.getById", name: "link.getById",
fctx, fctx,
fn: () => fn: () =>
ResultAsync.fromPromise( ResultAsync.fromPromise(
this.db.query.link.findFirst({ where: eq(link.id, id) }), this.db.query.link.findFirst({
where: eq(link.id, id),
with: {
device: true,
supportedApp: true,
},
}),
(e) => (e) =>
linkErrors.dbError( linkErrors.dbError(
fctx, fctx,
@@ -41,7 +53,7 @@ export class LinkRepository {
), ),
).andThen((row) => { ).andThen((row) => {
if (!row) return errAsync(linkErrors.linkNotFound(fctx, id)); if (!row) return errAsync(linkErrors.linkNotFound(fctx, id));
return okAsync(row as Link); return okAsync(row as LinkWithDevice);
}), }),
}); });
} }
@@ -54,7 +66,7 @@ export class LinkRepository {
ResultAsync.fromPromise( ResultAsync.fromPromise(
this.db.query.link.findFirst({ this.db.query.link.findFirst({
where: eq(link.token, token), where: eq(link.token, token),
with: { device: true }, with: { device: true, supportedApp: true },
}), }),
(e) => (e) =>
linkErrors.dbError( linkErrors.dbError(
@@ -81,9 +93,8 @@ export class LinkRepository {
.values({ .values({
token: data.token, token: data.token,
status: "active", status: "active",
appName: data.appName,
appPackage: data.appPackage,
linkedDeviceId: data.linkedDeviceId ?? null, linkedDeviceId: data.linkedDeviceId ?? null,
supportedAppId: data.supportedAppId,
expiresAt: data.expiresAt ?? null, expiresAt: data.expiresAt ?? null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),

View File

@@ -0,0 +1,45 @@
import { db } from "@pkg/db";
import { type Err } from "@pkg/result";
import { FlowExecCtx } from "@core/flow.execution.context";
import { ResultAsync } from "neverthrow";
import {
CreateSupportedApp,
SupportedApp,
UpdateSupportedApp,
} from "./data";
import { SupportedAppRepository } from "./repository";
export class SupportedAppController {
constructor(private repo: SupportedAppRepository) {}
list(fctx: FlowExecCtx): ResultAsync<SupportedApp[], Err> {
return this.repo.list(fctx);
}
getById(fctx: FlowExecCtx, id: number): ResultAsync<SupportedApp, Err> {
return this.repo.getById(fctx, id);
}
create(
fctx: FlowExecCtx,
data: CreateSupportedApp,
): ResultAsync<SupportedApp, Err> {
return this.repo.create(fctx, data);
}
update(
fctx: FlowExecCtx,
id: number,
data: UpdateSupportedApp,
): ResultAsync<SupportedApp, Err> {
return this.repo.update(fctx, id, data);
}
delete(fctx: FlowExecCtx, id: number): ResultAsync<boolean, Err> {
return this.repo.delete(fctx, id);
}
}
export function getSupportedAppController(): SupportedAppController {
return new SupportedAppController(new SupportedAppRepository(db));
}

View File

@@ -0,0 +1,28 @@
import * as v from "valibot";
export const supportedAppSchema = v.object({
id: v.number(),
title: v.string(),
packageName: v.string(),
createdAt: v.date(),
updatedAt: v.date(),
});
export type SupportedApp = v.InferOutput<typeof supportedAppSchema>;
export const createSupportedAppSchema = v.object({
title: v.pipe(v.string(), v.minLength(1)),
packageName: v.pipe(v.string(), v.minLength(1)),
});
export type CreateSupportedApp = v.InferOutput<
typeof createSupportedAppSchema
>;
export const updateSupportedAppSchema = v.partial(
v.object({
title: v.string(),
packageName: v.string(),
}),
);
export type UpdateSupportedApp = v.InferOutput<
typeof updateSupportedAppSchema
>;

View File

@@ -0,0 +1,59 @@
import { FlowExecCtx } from "@/core/flow.execution.context";
import { ERROR_CODES, type Err } from "@pkg/result";
import { getError } from "@pkg/logger";
export const supportedAppErrors = {
dbError: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Database operation failed",
description: "Please try again later",
detail,
}),
supportedAppNotFound: (fctx: FlowExecCtx, id: number): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.NOT_FOUND,
message: "Supported app not found",
description: "The requested supported app does not exist",
detail: `No supported app found with ID: ${id}`,
}),
listFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to list supported apps",
description: "Try again later",
detail,
}),
createFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to create supported app",
description: "Try again later",
detail,
}),
updateFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update supported app",
description: "Try again later",
detail,
}),
deleteFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to delete supported app",
description: "Try again later",
detail,
}),
};

View File

@@ -0,0 +1,149 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import { Database, asc, eq } from "@pkg/db";
import { supportedApp } from "@pkg/db/schema";
import { type Err } from "@pkg/result";
import { logger } from "@pkg/logger";
import { traceResultAsync } from "@core/observability";
import {
CreateSupportedApp,
SupportedApp,
UpdateSupportedApp,
} from "./data";
import { supportedAppErrors } from "./errors";
export class SupportedAppRepository {
constructor(private db: Database) {}
list(fctx: FlowExecCtx): ResultAsync<SupportedApp[], Err> {
return traceResultAsync({
name: "supportedApp.list",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db
.select()
.from(supportedApp)
.orderBy(asc(supportedApp.createdAt)),
(e) =>
supportedAppErrors.listFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map((rows) => rows as SupportedApp[]),
});
}
getById(fctx: FlowExecCtx, id: number): ResultAsync<SupportedApp, Err> {
return traceResultAsync({
name: "supportedApp.getById",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db.query.supportedApp.findFirst({
where: eq(supportedApp.id, id),
}),
(e) =>
supportedAppErrors.dbError(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((row) => {
if (!row) {
return errAsync(
supportedAppErrors.supportedAppNotFound(fctx, id),
);
}
return okAsync(row as SupportedApp);
}),
});
}
create(
fctx: FlowExecCtx,
data: CreateSupportedApp,
): ResultAsync<SupportedApp, Err> {
logger.info("Creating supported app", { ...fctx, title: data.title });
return traceResultAsync({
name: "supportedApp.create",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db
.insert(supportedApp)
.values({
title: data.title,
packageName: data.packageName,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
.execute(),
(e) =>
supportedAppErrors.createFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map((rows) => rows[0] as SupportedApp),
});
}
update(
fctx: FlowExecCtx,
id: number,
updates: UpdateSupportedApp,
): ResultAsync<SupportedApp, Err> {
return traceResultAsync({
name: "supportedApp.update",
fctx,
fn: () =>
this.getById(fctx, id).andThen(() =>
ResultAsync.fromPromise(
this.db
.update(supportedApp)
.set({ ...updates, updatedAt: new Date() })
.where(eq(supportedApp.id, id))
.returning()
.execute(),
(e) =>
supportedAppErrors.updateFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((rows) => {
if (!rows[0]) {
return errAsync(
supportedAppErrors.supportedAppNotFound(
fctx,
id,
),
);
}
return okAsync(rows[0] as SupportedApp);
}),
),
});
}
delete(fctx: FlowExecCtx, id: number): ResultAsync<boolean, Err> {
return traceResultAsync({
name: "supportedApp.delete",
fctx,
fn: () =>
this.getById(fctx, id).andThen(() =>
ResultAsync.fromPromise(
this.db
.delete(supportedApp)
.where(eq(supportedApp.id, id))
.execute(),
(e) =>
supportedAppErrors.deleteFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).map(() => true),
),
});
}
}

View File

@@ -19,7 +19,7 @@ export const settingsSchema = v.object({
debugKey: v.string(), debugKey: v.string(),
orchestratorApiUrl: v.string(), orchestratorApiUrl: v.string(),
wsScrcpySvcUrl: v.string(), publicWsScrcpySvcUrl: v.string(),
betterAuthUrl: v.string(), betterAuthUrl: v.string(),
betterAuthSecret: v.string(), betterAuthSecret: v.string(),
@@ -87,7 +87,7 @@ function loadSettings(): Settings {
"ORCHESTRATOR_API_URL", "ORCHESTRATOR_API_URL",
"http://localhost:3000", "http://localhost:3000",
), ),
wsScrcpySvcUrl: getEnv("WS_SCRCPY_SVC_URL"), publicWsScrcpySvcUrl: getEnv("PUBLIC_WS_SCRCPY_SVC_URL"),
betterAuthUrl: getEnv("BETTER_AUTH_URL"), betterAuthUrl: getEnv("BETTER_AUTH_URL"),
betterAuthSecret: getEnv("BETTER_AUTH_SECRET"), betterAuthSecret: getEnv("BETTER_AUTH_SECRET"),

1
ws-scrcpy Submodule

Submodule ws-scrcpy added at 49d2623184