Add kiosk desktop app

Tauri app for the Pi. Media runs through the native LiveKit SDK because
WebKitGTK has no WebRTC, with frames encoded in Rust and drawn to a canvas
under the annotation overlay.

Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD
This commit is contained in:
2026-08-09 17:09:16 -04:00
parent 31d55fce86
commit a0cbd88fa4
85 changed files with 8330 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
@import "tailwindcss";
@source "../../../packages/ui/src";
@theme {
--color-surface-0: #0b0d10;
--color-surface-1: #14181d;
--color-surface-2: #1c2229;
--color-ink-0: #f4f6f8;
--color-ink-1: #a8b3c0;
--color-ink-2: #6b7885;
--color-accent: #4f7cff;
--color-danger: #ff3b52;
--color-success: #21c17a;
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace;
}
* {
border-radius: 0 !important;
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
background-color: var(--color-surface-0);
color: var(--color-ink-0);
font-family: var(--font-sans);
user-select: none;
-webkit-font-smoothing: antialiased;
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PiStation Kiosk</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="h-full">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
import type { KioskLayout, KioskPinResponse, KioskSessionResponse } from "@pistation/shared-types";
export interface RegisterResult {
kioskId: string;
kioskToken: string;
roomName: string;
livekitUrl: string;
rotationSeconds: number;
}
export class KioskApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(
serverUrl: string,
path: string,
options: { method?: string; token?: string; body?: unknown } = {}
): Promise<T> {
const headers: Record<string, string> = {};
if (options.body !== undefined) headers["content-type"] = "application/json";
if (options.token) headers["authorization"] = `Bearer ${options.token}`;
let response: Response;
try {
response = await fetch(`${serverUrl.replace(/\/$/, "")}${path}`, {
method: options.method ?? "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body)
});
} catch {
throw new KioskApiError(0, `could not reach the server at ${serverUrl}`);
}
if (response.status === 204) return undefined as T;
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = (payload as { message?: string })?.message ?? `request failed: ${path}`;
throw new KioskApiError(response.status, message);
}
return payload as T;
}
export function registerKiosk(
serverUrl: string,
enrollmentToken: string,
hardwareId: string
): Promise<RegisterResult> {
return request<RegisterResult>(serverUrl, "/api/kiosk/register", {
method: "POST",
body: { enrollmentToken, hardwareId }
});
}
export function fetchPin(serverUrl: string, token: string): Promise<KioskPinResponse> {
return request<KioskPinResponse>(serverUrl, "/api/kiosk/pin", { token });
}
export function fetchSession(serverUrl: string, token: string): Promise<KioskSessionResponse> {
return request<KioskSessionResponse>(serverUrl, "/api/kiosk/session", { token });
}
export function fetchLayout(serverUrl: string, token: string): Promise<KioskLayout> {
return request<KioskLayout>(serverUrl, "/api/kiosk/layout", { token });
}
export function sendHeartbeat(
serverUrl: string,
token: string,
metrics: unknown
): Promise<void> {
return request<void>(serverUrl, "/api/kiosk/heartbeat", {
method: "POST",
token,
body: { metrics }
});
}
@@ -0,0 +1,24 @@
<script lang="ts">
import { formatPin } from "@pistation/shared-types";
let {
pin,
joinUrl
}: {
pin: string | null;
joinUrl: string;
} = $props();
</script>
{#if pin}
<div class="absolute top-6 right-6 flex flex-col items-end gap-1 bg-surface-0/80 px-5 py-3">
{#if joinUrl}
<p class="text-xs tracking-wide text-ink-2 uppercase">Join at {joinUrl}</p>
{:else}
<p class="text-xs tracking-wide text-ink-2 uppercase">Join code</p>
{/if}
<p class="font-mono text-3xl leading-none font-bold tracking-[0.08em] text-ink-0">
{formatPin(pin)}
</p>
</div>
{/if}
@@ -0,0 +1,50 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { Logo } from "@pistation/ui";
import { getCurrentWindow } from "@tauri-apps/api/window";
let { status }: { status: string } = $props();
const appWindow = getCurrentWindow();
async function toggleMaximize() {
if (await appWindow.isMaximized()) {
await appWindow.unmaximize();
} else {
await appWindow.maximize();
}
}
</script>
<header
data-tauri-drag-region
class="flex h-9 shrink-0 items-center gap-3 bg-surface-1 pr-px pl-3 select-none"
>
<Logo size={18} />
<span data-tauri-drag-region class="text-xs font-medium text-ink-1">PiStation kiosk</span>
<span class="text-xs text-ink-2">{status}</span>
<div class="ml-auto flex h-full">
<button
onclick={() => appWindow.minimize()}
aria-label="Minimise"
class="flex h-full w-11 items-center justify-center text-ink-1 transition-colors hover:bg-surface-2"
>
<Icon icon="ph:minus-bold" width="14" />
</button>
<button
onclick={toggleMaximize}
aria-label="Maximise"
class="flex h-full w-11 items-center justify-center text-ink-1 transition-colors hover:bg-surface-2"
>
<Icon icon="ph:square-bold" width="12" />
</button>
<button
onclick={() => appWindow.close()}
aria-label="Close"
class="flex h-full w-11 items-center justify-center text-ink-1 transition-colors hover:bg-danger hover:text-white"
>
<Icon icon="ph:x-bold" width="14" />
</button>
</div>
</header>
@@ -0,0 +1,84 @@
<script lang="ts">
import { Channel, invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
let { visible = false }: { visible?: boolean } = $props();
let canvas = $state<HTMLCanvasElement | null>(null);
let hasFrame = $state(false);
// The channel is opened once for the life of the page. Opening it per presentation left
// Rust holding a callback id the webview had already discarded.
onMount(() => {
const channel = new Channel<ArrayBuffer | number[]>();
let disposed = false;
let isDecoding = false;
let pending: ImageBitmap | null = null;
let frameRequest = 0;
channel.onmessage = (message) => {
// Decoding one frame at a time means a slow decode drops frames instead of
// building a backlog, which would show up as growing latency.
if (disposed || isDecoding) return;
const bytes = message instanceof ArrayBuffer ? message : new Uint8Array(message);
isDecoding = true;
void createImageBitmap(new Blob([bytes], { type: "image/jpeg" }))
.then((bitmap) => {
if (disposed) {
bitmap.close();
return;
}
pending?.close();
pending = bitmap;
})
.catch(() => undefined)
.finally(() => {
isDecoding = false;
});
};
// Painting on the animation frame ties output to the display refresh, so frames are
// never drawn twice or torn between refreshes.
function paint() {
frameRequest = requestAnimationFrame(paint);
if (!pending || !canvas) return;
const context = canvas.getContext("2d", { alpha: false });
if (!context) return;
if (canvas.width !== pending.width || canvas.height !== pending.height) {
canvas.width = pending.width;
canvas.height = pending.height;
}
context.drawImage(pending, 0, 0);
pending.close();
pending = null;
hasFrame = true;
}
frameRequest = requestAnimationFrame(paint);
void invoke("video_subscribe", { channel }).catch((error) =>
console.error("[pistation] video_subscribe failed", error)
);
return () => {
disposed = true;
cancelAnimationFrame(frameRequest);
pending?.close();
void invoke("video_unsubscribe").catch(() => undefined);
};
});
</script>
<canvas
bind:this={canvas}
class="absolute inset-0 h-full w-full bg-black"
class:hidden={!visible}
class:opacity-0={!hasFrame}
style="object-fit: contain; image-rendering: auto;"
></canvas>
+293
View File
@@ -0,0 +1,293 @@
import type {
DataEnvelope,
KioskLayout,
RoomMode,
WhiteboardElement
} from "@pistation/shared-types";
import {
isNightModeActive,
isTopic,
mergeWhiteboardElements,
PIN_GRACE_SECONDS
} from "@pistation/shared-types";
import type { ConnectionStatus } from "@pistation/client-core";
import type { AnnotationState } from "@pistation/client-core/annotations";
import {
applyAnnotationEvent,
createAnnotationState,
prunePointers
} from "@pistation/client-core/annotations";
import { invoke } from "@tauri-apps/api/core";
import { NativeRoom, type RoomParticipant } from "./native-room";
import {
fetchLayout,
fetchPin,
fetchSession,
KioskApiError,
registerKiosk,
sendHeartbeat
} from "./api";
const HEARTBEAT_INTERVAL_MS = 30_000;
const LAYOUT_INTERVAL_MS = 60_000;
const PIN_FALLBACK_INTERVAL_MS = 20_000;
interface KioskConfig {
serverUrl: string;
enrollmentToken: string;
kioskToken: string;
kioskId: string;
roomName: string;
livekitUrl: string;
joinUrl: string;
}
export class KioskController {
status = $state<ConnectionStatus>("idle");
provisioningError = $state<string | null>(null);
pin = $state<string | null>(null);
layout = $state<KioskLayout | null>(null);
mode = $state<RoomMode>("idle");
annotations = $state<AnnotationState>(createAnnotationState());
whiteboardElements = $state<WhiteboardElement[]>([]);
participants = $state<RoomParticipant[]>([]);
isScreenActive = $state(false);
screenWidth = $state(0);
screenHeight = $state(0);
joinUrl = $state("");
mediaBaseUrl = $state("");
isNight = $state(false);
connectionError = $state<string | null>(null);
private config: KioskConfig | null = null;
private connection: NativeRoom | null = null;
private timers: ReturnType<typeof setInterval>[] = [];
private pinTimer: ReturnType<typeof setTimeout> | null = null;
private hasRetriedEnrollment = false;
async start(): Promise<void> {
try {
this.config = await invoke<KioskConfig>("load_config");
} catch {
this.provisioningError = "Could not read the kiosk configuration file.";
return;
}
if (!this.config.serverUrl) {
this.provisioningError = "No server URL configured. Provision this kiosk first.";
return;
}
this.joinUrl = this.config.joinUrl;
this.mediaBaseUrl = this.config.serverUrl;
if (!this.config.kioskToken) {
const registered = await this.register();
if (!registered) return;
}
void this.refreshPin();
void this.refreshLayout();
void this.connectToRoom();
this.addInterval(() => void this.heartbeat(), HEARTBEAT_INTERVAL_MS);
this.addInterval(() => void this.refreshLayout(), LAYOUT_INTERVAL_MS);
this.addInterval(() => {
this.annotations = prunePointers(this.annotations, Date.now());
}, 1000);
this.addInterval(() => this.evaluateNightMode(), 20_000);
}
async stop(): Promise<void> {
for (const timer of this.timers) clearInterval(timer);
this.timers = [];
if (this.pinTimer) clearTimeout(this.pinTimer);
this.pinTimer = null;
await this.connection?.stop();
this.connection = null;
}
private addInterval(action: () => void, intervalMs: number): void {
this.timers.push(setInterval(action, intervalMs));
}
private async register(): Promise<boolean> {
if (!this.config) return false;
if (!this.config.enrollmentToken) {
this.provisioningError = "This kiosk has no enrollment token. Add one in the admin panel.";
return false;
}
try {
const hardwareId = await invoke<string>("hardware_id");
const result = await registerKiosk(
this.config.serverUrl,
this.config.enrollmentToken,
hardwareId
);
this.config = {
...this.config,
kioskId: result.kioskId,
kioskToken: result.kioskToken,
roomName: result.roomName,
livekitUrl: result.livekitUrl,
enrollmentToken: ""
};
await invoke("save_config", { config: this.config });
this.provisioningError = null;
return true;
} catch (error) {
this.provisioningError =
error instanceof Error ? error.message : "Enrollment failed. Check the token.";
return false;
}
}
private async handleRejectedToken(): Promise<boolean> {
if (!this.config) return false;
if (this.hasRetriedEnrollment) {
this.provisioningError =
"This kiosk's credentials were rejected. Rotate its enrollment token in the admin panel and run the installer again.";
return false;
}
this.hasRetriedEnrollment = true;
this.config = { ...this.config, kioskToken: "" };
await invoke("save_config", { config: this.config });
this.config = await invoke<KioskConfig>("load_config");
if (!(await this.register())) return false;
void this.refreshPin();
void this.refreshLayout();
void this.connectToRoom();
return true;
}
private async refreshPin(): Promise<void> {
if (!this.config?.kioskToken) return;
let delayMs = PIN_FALLBACK_INTERVAL_MS;
try {
const issued = await fetchPin(this.config.serverUrl, this.config.kioskToken);
this.pin = issued.pin;
delayMs = Math.max(5000, issued.expiresAt - Date.now() - PIN_GRACE_SECONDS * 1000);
} catch (error) {
this.pin = null;
if (error instanceof KioskApiError && error.status === 401) {
if (await this.handleRejectedToken()) return;
}
}
if (this.pinTimer) clearTimeout(this.pinTimer);
this.pinTimer = setTimeout(() => void this.refreshPin(), delayMs);
}
private async refreshLayout(): Promise<void> {
if (!this.config?.kioskToken) return;
const layout = await fetchLayout(this.config.serverUrl, this.config.kioskToken).catch(
() => null
);
if (!layout) return;
this.layout = layout;
this.evaluateNightMode();
}
private evaluateNightMode(): void {
this.isNight = this.layout ? isNightModeActive(this.layout.nightMode) : false;
}
private async heartbeat(): Promise<void> {
if (!this.config?.kioskToken) return;
const metrics = await invoke("collect_metrics").catch(() => null);
await sendHeartbeat(this.config.serverUrl, this.config.kioskToken, metrics).catch(
() => undefined
);
}
private async connectToRoom(): Promise<void> {
if (!this.config?.kioskToken) return;
const config = this.config;
this.connection = new NativeRoom(
async () => {
const session = await fetchSession(config.serverUrl, config.kioskToken);
return { livekitUrl: session.livekitUrl, accessToken: session.accessToken };
},
{
onStatus: (status, detail) => {
this.status = status;
if (status === "connected") {
this.connectionError = null;
return;
}
if (status === "disconnected" || status === "failed") {
this.connectionError = detail;
this.isScreenActive = false;
this.mode = "idle";
this.participants = [];
this.annotations = createAnnotationState();
}
},
onEnvelope: (envelope) => this.handleEnvelope(envelope),
onVideo: (active, width, height) => {
this.isScreenActive = active;
this.screenWidth = width;
this.screenHeight = height;
if (active && this.mode === "idle") this.mode = "presentation";
if (!active && this.mode === "presentation") this.mode = "idle";
},
onParticipants: (participants) => {
this.participants = participants;
}
}
);
await this.connection.start();
}
private handleEnvelope(envelope: DataEnvelope): void {
if (isTopic(envelope, "annotation")) {
this.annotations = applyAnnotationEvent(
this.annotations,
envelope.payload,
envelope.senderId
);
return;
}
if (isTopic(envelope, "control")) {
const event = envelope.payload;
if (event.type === "mode.set") this.mode = event.mode;
if (event.type === "room.state") this.mode = event.state.mode;
return;
}
if (isTopic(envelope, "whiteboard")) {
const event = envelope.payload;
if (event.type === "whiteboard.patch") {
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
}
if (event.type === "whiteboard.snapshot") {
this.whiteboardElements = event.elements;
}
if (event.type === "whiteboard.clear") {
this.whiteboardElements = [];
}
}
}
}
+160
View File
@@ -0,0 +1,160 @@
import type { ConnectionStatus } from "@pistation/client-core";
import type { DataEnvelope } from "@pistation/shared-types";
import { parseEnvelope } from "@pistation/shared-types";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
interface StatusEvent {
status: string;
detail: string | null;
}
interface DataEvent {
envelope: string;
}
interface VideoEvent {
active: boolean;
width: number;
height: number;
}
export interface RoomParticipant {
identity: string;
displayName: string;
}
interface ParticipantsEvent {
participants: RoomParticipant[];
}
export interface Credentials {
livekitUrl: string;
accessToken: string;
}
export interface NativeRoomHandlers {
onStatus(status: ConnectionStatus, detail: string | null): void;
onEnvelope(envelope: DataEnvelope): void;
onVideo(active: boolean, width: number, height: number): void;
onParticipants(participants: RoomParticipant[]): void;
}
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000];
function toConnectionStatus(status: string): ConnectionStatus {
switch (status) {
case "connecting":
case "connected":
case "reconnecting":
case "disconnected":
return status;
default:
return "failed";
}
}
/// Media lives in Rust. This drives the native LiveKit client over Tauri commands and
/// turns its events back into the same shape the web client works with.
export class NativeRoom {
private fetchCredentials: () => Promise<Credentials>;
private handlers: NativeRoomHandlers;
private unlisteners: UnlistenFn[] = [];
private retryIndex = 0;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(fetchCredentials: () => Promise<Credentials>, handlers: NativeRoomHandlers) {
this.fetchCredentials = fetchCredentials;
this.handlers = handlers;
}
async start(): Promise<void> {
this.stopped = false;
await this.bindEvents();
await this.attempt();
}
async stop(): Promise<void> {
this.stopped = true;
this.clearRetry();
for (const unlisten of this.unlisteners) unlisten();
this.unlisteners = [];
await invoke("room_disconnect").catch(() => undefined);
}
private async bindEvents(): Promise<void> {
this.unlisteners.push(
await listen<StatusEvent>("room://status", (event) => {
const status = toConnectionStatus(event.payload.status);
this.handlers.onStatus(status, event.payload.detail);
if (status === "connected") {
this.retryIndex = 0;
return;
}
if (status === "disconnected" || status === "failed") {
this.scheduleRetry();
}
})
);
this.unlisteners.push(
await listen<DataEvent>("room://data", (event) => {
const envelope = parseEnvelope(event.payload.envelope);
if (envelope) this.handlers.onEnvelope(envelope);
})
);
this.unlisteners.push(
await listen<VideoEvent>("room://video", (event) => {
this.handlers.onVideo(event.payload.active, event.payload.width, event.payload.height);
})
);
this.unlisteners.push(
await listen<ParticipantsEvent>("room://participants", (event) => {
this.handlers.onParticipants(event.payload.participants);
})
);
}
private async attempt(): Promise<void> {
if (this.stopped) return;
try {
const credentials = await this.fetchCredentials();
await invoke("room_connect", {
url: credentials.livekitUrl,
token: credentials.accessToken
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.error(`[pistation] native room connect failed: ${detail}`);
this.handlers.onStatus("failed", detail);
this.scheduleRetry();
}
}
private scheduleRetry(): void {
if (this.stopped || this.retryTimer) return;
const delay = RETRY_DELAYS_MS[Math.min(this.retryIndex, RETRY_DELAYS_MS.length - 1)];
this.retryIndex += 1;
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
void this.attempt();
}, delay);
}
private clearRetry(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
}
@@ -0,0 +1,7 @@
<script lang="ts">
import "../app.css";
let { children } = $props();
</script>
{@render children()}
+5
View File
@@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;
+117
View File
@@ -0,0 +1,117 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import {
AnnotationOverlay,
Logo,
NightSurface,
WhiteboardCanvas,
WidgetSurface
} from "@pistation/ui";
import { onDestroy, onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import JoinBadge from "$lib/components/JoinBadge.svelte";
import TitleBar from "$lib/components/TitleBar.svelte";
import VideoCanvas from "$lib/components/VideoCanvas.svelte";
import { KioskController } from "$lib/kiosk.svelte";
let controller = $state<KioskController | null>(null);
let isKioskMode = $state(true);
const participantLabels = $derived(
new Map((controller?.participants ?? []).map((p) => [p.identity, p.displayName]))
);
onMount(() => {
void invoke<boolean>("is_kiosk_mode")
.then((value) => (isKioskMode = value))
.catch(() => (isKioskMode = true));
const instance = new KioskController();
controller = instance;
void instance.start();
});
onDestroy(() => {
void controller?.stop();
});
</script>
{#if controller}
{@const kiosk = controller}
{@const isPresenting = kiosk.mode === "presentation" && kiosk.isScreenActive}
<div class="flex h-screen w-screen flex-col overflow-hidden bg-surface-0">
{#if !isKioskMode}
<TitleBar status={kiosk.status} />
{/if}
<div class="relative min-h-0 flex-1 overflow-hidden">
<VideoCanvas visible={isPresenting} />
{#if kiosk.provisioningError}
<div class="flex h-full flex-col items-center justify-center gap-6 px-16 text-center">
<Logo size={96} />
<div>
<h1 class="text-3xl font-semibold">This kiosk is not set up yet</h1>
<p class="mt-3 max-w-2xl text-lg text-ink-2">{kiosk.provisioningError}</p>
</div>
<p class="text-sm text-ink-2">
Add the server URL and enrollment token to /boot/firmware/pistation.json and restart.
</p>
</div>
{:else if kiosk.mode === "whiteboard"}
<WhiteboardCanvas elements={kiosk.whiteboardElements} readOnly={true} />
{:else if isPresenting}
<AnnotationOverlay annotations={kiosk.annotations} labels={participantLabels} />
{:else if kiosk.layout && kiosk.isNight}
<NightSurface layout={kiosk.layout} pin={kiosk.pin} />
{:else if kiosk.layout}
<WidgetSurface
layout={kiosk.layout}
pin={kiosk.pin}
joinUrl={kiosk.joinUrl}
mediaBaseUrl={kiosk.mediaBaseUrl}
/>
{:else}
<div class="flex h-full items-center justify-center">
<Icon icon="ph:circle-notch-bold" width="48" class="animate-spin text-ink-2" />
</div>
{/if}
{#if (isPresenting || kiosk.mode === "whiteboard") && !kiosk.provisioningError}
<JoinBadge pin={kiosk.pin} joinUrl={kiosk.joinUrl} />
{/if}
{#if kiosk.participants.length > 0 && !kiosk.provisioningError}
<div
class="absolute bottom-6 left-6 flex items-center gap-2 bg-surface-1/90 px-4 py-2.5 text-ink-1"
>
<Icon icon="ph:users-bold" width="18" />
<span class="text-lg font-semibold text-ink-0">{kiosk.participants.length}</span>
<span class="text-sm">
{kiosk.participants.length === 1 ? "person connected" : "people connected"}
</span>
</div>
{/if}
{#if !kiosk.provisioningError && kiosk.status !== "connected" && kiosk.status !== "idle"}
<div class="absolute right-6 bottom-6 flex max-w-lg items-start gap-2 bg-surface-1 px-4 py-2">
<Icon
icon={kiosk.status === "connecting" ? "ph:circle-notch-bold" : "ph:wifi-slash-bold"}
width="18"
class={kiosk.status === "connecting" ? "mt-0.5 animate-spin text-ink-2" : "mt-0.5 text-danger"}
/>
<div class="min-w-0">
<span class="text-sm text-ink-1">
{kiosk.status === "connecting" ? "Connecting" : "Reconnecting"}
</span>
{#if kiosk.connectionError}
<p class="text-xs break-words text-ink-2">{kiosk.connectionError}</p>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}