diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ecafda --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Kiosk + +Build, sign and install Officer's iOS and Android apps on your own registered devices. What EAS does, +self-hosted. + +- **A catalogue of builds**, newest per app per platform, with an Install button that hands + `itms-services://` to iOS or a plain URL to Android. +- **A per-user key** that authorises artefact downloads. Held in memory by the app and re-read on demand, so + rotating one costs nothing. +- **Device records** — who owns which iPhone or iPad, and whether Apple knows about it yet. + +## Status — read before installing + +**Nothing here has run.** Ported on 2026-08-18 from the standalone service that was called OAS. The +authenticated API and the two panels exist and typecheck; no endpoint has answered a request. + +### What works, in principle + +`GET /apps`, `GET /apps/:appName`, `GET /key`, `POST /key/rotate`, +`GET /install/:appName/:platform/:version`, `POST /installs`, and the device and build verbs. All under +`/api/kiosk`, all requiring a platform session. + +### What is missing, and why + +**Installing will not work yet.** Four requests in the flow arrive with no session — iOS `installd` fetching +the manifest and the `.ipa`, the device posting its UDID, and Safari downloading a `.mobileconfig` — and a +plugin cannot serve an unauthenticated route today. `UNPROTECTED_API_PREFIXES` is hardcoded in the platform +and plugin routers mount only behind the account gate. + +So `GET /install/...` returns a correct-looking URL pointing at a route that does not exist. That is +deliberate: the URL shape is what the mobile app builds against, and it stops being a dead end the day the +platform gains `unauthenticatedPaths`. Those routes then land in `api/public.ts`, kept separate from +`api/router.ts` so the two surfaces cannot be confused by anyone reading either. + +**Enrolment is not ported** for the same reason — it is entirely public routes. + +**Artefacts arrive by rsync**, into `$OFFICER_ROOT/data/kiosk/`. The plugin records metadata about files +already on disk; it does not accept uploads, because the platform's shared upload route caps at 5MB and no +`.ipa` will ever satisfy that. + +## Notes + +- `PUBLIC_URL` must be **HTTPS with a certificate iOS already trusts**. It fetches the manifest and binary + itself and declines silently otherwise, which looks exactly like a broken build. +- Table names are prefixed `kiosk_`. There are no migrations — see the note at the top of `db/schema.ts`. +- The plugin is currently `appName: kiosk`. If the plugin *storefront* takes that name, this one is renamed; + see `docs/kiosk-ecosystem.md` in the platform. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..fe46b08 --- /dev/null +++ b/api/router.ts @@ -0,0 +1,190 @@ +import type { User } from 'types'; +import { createRouter } from '@@/create-router'; +import * as errors from '@@/custom-errors'; +import { isSuperAdmin } from '@@/super-admin'; +import { + createDevice, + deleteBuild, + deleteDevice, + getBuild, + getBuildById, + getDevice, + getOrCreateUserKey, + listBuildsForApp, + listDevices, + listLatestBuilds, + recordInstall, + rotateUserKey, + setBuildPinned, + updateDevice, + upsertBuild, +} from '../db/queries'; +import type { KioskApp, KioskBuild } from '../db/types'; +import { installUrl } from '../artefacts'; + +/* + /api/kiosk/* — the AUTHENTICATED surface. + + Everything here requires a platform session, which is what the whole port exists to achieve: who may + install an app stops being "whoever holds the invite link" and becomes "whoever has an account here". + + The four requests that CANNOT carry a session — iOS `installd` fetching the manifest and the `.ipa`, the + device posting its UDID, and Safari downloading a `.mobileconfig` — are deliberately NOT in this file. + They need a platform primitive that does not exist yet (`unauthenticatedPaths`), and when it does they + belong in `api/public.ts` so the two surfaces cannot be confused by anyone reading either one. + + Owner-only routes say so inline with `isSuperAdmin`. That is not a second permission: the platform gives a + plugin ONE permission covering its whole route surface, so anything narrower is this router's own job. +*/ +export const router = createRouter(); + +const ownerOnly = async (user: User) => { + if (!(await isSuperAdmin(user))) throw errors.FORBIDDEN('That is owner-only'); +}; + +// ── Catalogue ── + +/** Every app with its newest build per platform. What the mobile app and the install page both open with. */ +router.get('/apps', async (ctx) => { + const builds = await listLatestBuilds(); + const apps = new Map(); + for (const build of builds) { + const app = apps.get(build.appName) ?? { + appName: build.appName, + displayName: build.displayName, + ios: null, + android: null, + }; + if (build.platform === 'ios') app.ios = build; + else app.android = build; + apps.set(build.appName, app); + } + return ctx.json({ apps: [...apps.values()].sort((a, b) => a.displayName.localeCompare(b.displayName)) }); +}); + +/** One app, every retained version, both platforms. */ +router.get('/apps/:appName', async (ctx) => { + const builds = await listBuildsForApp(ctx.req.param('appName')); + if (!builds.length) throw errors.NOT_FOUND('No builds for that app'); + return ctx.json({ + appName: builds[0]!.appName, + displayName: builds[0]!.displayName, + versions: builds, + }); +}); + +router.get('/apps/:appName/:platform/:version', async (ctx) => { + const { appName, platform, version } = ctx.req.param(); + const build = await getBuild(appName, platform, version); + if (!build) throw errors.NOT_FOUND('No such build'); + return ctx.json(build); +}); + +// ── The key ── +// +// Generated lazily on first ask. The app holds it in memory only and re-fetches whenever it builds an +// install link, which is precisely what makes rotation free. + +router.get('/key', async (ctx) => ctx.json({ key: await getOrCreateUserKey(ctx.get('user').id) })); + +router.post('/key/rotate', async (ctx) => ctx.json({ key: await rotateUserKey(ctx.get('user').id) })); + +// ── Install ── + +/** + * The string to hand to the OS — `itms-services://` for iOS, a plain URL for Android. + * + * Built per request and never stored, because the URL inside it carries the CALLING USER'S key. Two people + * asking for the same build get different links, which is the whole point: the artefact route is public and + * the key is what authorises it. + */ +router.get('/install/:appName/:platform/:version', async (ctx) => { + const { appName, platform, version } = ctx.req.param(); + const build = await getBuild(appName, platform, version); + if (!build) throw errors.NOT_FOUND('No such build'); + const key = await getOrCreateUserKey(ctx.get('user').id); + return ctx.json(installUrl(build, key)); +}); + +/** Reported by the app after an install completes. Best-effort; see `recordInstall`. */ +router.post('/installs', async (ctx) => { + const body = ctx.get('body') as { buildId?: number; deviceId?: number }; + if (!body?.buildId) throw errors.BAD_REQUEST('buildId is required'); + const build = await getBuildById(body.buildId); + if (!build) throw errors.NOT_FOUND('No such build'); + await recordInstall(ctx.get('user').id, body.buildId, body.deviceId); + return ctx.json({ ok: true }); +}); + +// ── Devices ── +// +// A member sees their own; the owner sees everyone's. Enrolment itself needs the public callback and is not +// here yet. + +router.get('/devices', async (ctx) => { + const user = ctx.get('user'); + const mine = !(await isSuperAdmin(user)); + return ctx.json({ devices: await listDevices(mine ? user.id : undefined) }); +}); + +router.post('/devices', async (ctx) => { + const body = ctx.get('body') as { label?: string; userId?: number }; + const user = ctx.get('user'); + const label = body?.label?.trim(); + if (!label) throw errors.BAD_REQUEST('label is required'); + // Only the owner may mint a device for somebody else; everyone else gets their own. + const forUser = body?.userId && (await isSuperAdmin(user)) ? body.userId : user.id; + return ctx.json(await createDevice(forUser, label)); +}); + +/** Rename, or set the kind. Yours, or anyone's if you are the owner. */ +router.patch('/devices/:id', async (ctx) => { + const device = await getDevice(Number(ctx.req.param('id'))); + if (!device) throw errors.NOT_FOUND('No such device'); + const user = ctx.get('user'); + if (device.userId !== user.id && !(await isSuperAdmin(user))) throw errors.FORBIDDEN('That is not your device'); + const body = ctx.get('body') as { label?: string; kind?: string }; + await updateDevice(device.id, { + ...(body?.label ? { label: body.label.trim() } : {}), + ...(body?.kind ? { kind: body.kind } : {}), + }); + return ctx.json({ ok: true }); +}); + +router.delete('/devices/:id', async (ctx) => { + const device = await getDevice(Number(ctx.req.param('id'))); + if (!device) throw errors.NOT_FOUND('No such device'); + const user = ctx.get('user'); + if (device.userId !== user.id && !(await isSuperAdmin(user))) throw errors.FORBIDDEN('That is not your device'); + await deleteDevice(device.id); + return ctx.json({ ok: true }); +}); + +// ── Publishing — owner only ── +// +// Artefacts arrive by rsync from the build machine, which already works and keeps a credential it already +// has. So this records METADATA about a file that is already on disk rather than accepting an upload: the +// platform's shared upload route caps at 5MB, which no `.ipa` will ever satisfy. + +router.post('/builds', async (ctx) => { + await ownerOnly(ctx.get('user')); + const body = ctx.get('body') as Partial; + for (const field of ['appName', 'displayName', 'platform', 'version', 'bundleId', 'artefactPath'] as const) { + if (!body?.[field]) throw errors.BAD_REQUEST(`${field} is required`); + } + return ctx.json(await upsertBuild(body as Parameters[0])); +}); + +router.post('/builds/:id/pin', async (ctx) => { + await ownerOnly(ctx.get('user')); + const body = ctx.get('body') as { pinned?: boolean }; + await setBuildPinned(Number(ctx.req.param('id')), body?.pinned !== false); + return ctx.json({ ok: true }); +}); + +router.delete('/builds/:id', async (ctx) => { + await ownerOnly(ctx.get('user')); + const removed = await deleteBuild(Number(ctx.req.param('id'))); + if (!removed) throw errors.NOT_FOUND('No such build'); + return ctx.json({ ok: true }); +}); diff --git a/artefacts.ts b/artefacts.ts new file mode 100644 index 0000000..f834a10 --- /dev/null +++ b/artefacts.ts @@ -0,0 +1,58 @@ +import { join } from 'node:path'; +import { OFFICER_ROOT } from '@@/data-path'; +import type { KioskBuild } from './db/types'; + +/* + Where the artefacts live, and why not in this directory. + + `$OFFICER_ROOT/data/kiosk///`. NOT under `plugins/kiosk/`, and that is not a preference: + a plugin directory is a git clone that an update replaces, so artefacts kept there would vanish on upgrade with + nothing logging it. `DATA_PATH` proper is keyed by user email and these are not per-user — a build is one + file that many people install. + + Builds arrive by rsync from the Mac, which already works and keeps a credential it already has. The plugin + records metadata about a file already on disk rather than accepting an upload; the platform's shared upload + route caps at 5MB and no `.ipa` will ever satisfy that. +*/ +export const KIOSK_ROOT = join(OFFICER_ROOT, 'data', 'kiosk'); + +/** Absolute path to a build's artefact. `artefactPath` is stored relative, because the root moves. */ +export const artefactPath = (build: KioskBuild): string => join(KIOSK_ROOT, build.artefactPath); + +/** + * What to hand the OS to start an install. + * + * iOS cannot be given a `.ipa` directly — it takes an `itms-services://` URL naming a **manifest**, which + * the OS fetches itself and which in turn names the binary. Both of those fetches arrive with no session, + * which is why the key is in the query string rather than a header: `installd` is not our code and will not + * set one. + * + * Android needs none of this. An `.apk` installs from a plain URL. + * + * `PUBLIC_URL` rather than a literal: the manifest is the one artefact that embeds a hostname, and the + * standalone service learned this the hard way — its publisher and its server disagreed about the default, + * which was invisible until a restart pointed every Install button at a host that no longer served this. + */ +/** + * The externally reachable base for this server. + * + * Read from the environment rather than from `officer-url.mjs`, which exports only loopback URLs — those + * are for one process talking to another on the same box, and iOS is neither. + * + * **iOS requires HTTPS with a certificate it already trusts** for both the manifest and the binary; it + * fetches them itself and declines silently when it does not like the certificate, which looks exactly + * like a broken build. So a `PUBLIC_URL` that is http, an IP, or a name only the tailnet resolves will + * produce installs that fail with nothing useful said. + */ +const publicBase = (): string => { + const url = process.env.PUBLIC_URL?.replace(/\/$/, ''); + if (!url) throw new Error('PUBLIC_URL is not set — Kiosk cannot build an install URL without it'); + return url; +}; + +export function installUrl(build: KioskBuild, key: string): { url: string; kind: 'itms' | 'apk' } { + const base = `${publicBase()}/api/kiosk/artefact/${encodeURIComponent(build.appName)}/${build.platform}/${encodeURIComponent(build.version)}`; + if (build.platform === 'android') return { url: `${base}/binary?k=${encodeURIComponent(key)}`, kind: 'apk' }; + const manifest = `${base}/manifest.plist?k=${encodeURIComponent(key)}`; + return { url: `itms-services://?action=download-manifest&url=${encodeURIComponent(manifest)}`, kind: 'itms' }; +} diff --git a/db/queries.ts b/db/queries.ts new file mode 100644 index 0000000..9fac855 --- /dev/null +++ b/db/queries.ts @@ -0,0 +1,162 @@ +import { and, desc, eq, sql } from 'drizzle-orm'; +import { createHash, randomBytes } from 'node:crypto'; +import { db } from 'officerdb/db'; +import { decryptSecret, encryptSecret } from 'officerdb/crypto'; +import { kioskBuilds, kioskDevices, kioskInstalls, kioskUserKeys } from './schema'; +import type { KioskBuild, KioskBuildInsert, KioskDevice } from './types'; + +const PURPOSE = 'kiosk'; + +// ── Builds ── + +/** + * The newest build per app per platform — what a catalogue screen and the mobile app both open with. + * + * `DISTINCT ON` rather than fetching everything and reducing in JS: the store reached 101 artefacts on the + * standalone service before retention was added, and "newest per group" is the one thing Postgres does in + * the index that the application would do in a loop. + */ +export async function listLatestBuilds(): Promise { + const rows = await db.execute(sql` + select distinct on (app_name, platform) * + from kiosk_builds + order by app_name, platform, version desc + `); + return ((rows as unknown as { rows?: KioskBuild[] }).rows ?? (rows as unknown as KioskBuild[])) ?? []; +} + +/** Every retained version of one app, newest first, both platforms. */ +export async function listBuildsForApp(appName: string): Promise { + return db.select().from(kioskBuilds).where(eq(kioskBuilds.appName, appName)).orderBy(desc(kioskBuilds.version)); +} + +export async function getBuild(appName: string, platform: string, version: string): Promise { + const [row] = await db + .select() + .from(kioskBuilds) + .where( + and(eq(kioskBuilds.appName, appName), eq(kioskBuilds.platform, platform), eq(kioskBuilds.version, version)), + ); + return row; +} + +export async function getBuildById(id: number): Promise { + const [row] = await db.select().from(kioskBuilds).where(eq(kioskBuilds.id, id)); + return row; +} + +/** + * Record a published build. Upsert, because publishing is a rerunnable step on the Mac and a re-publish of + * the same version is a correction rather than an error. + */ +export async function upsertBuild(build: KioskBuildInsert): Promise { + const [row] = await db + .insert(kioskBuilds) + .values(build) + .onConflictDoUpdate({ + target: [kioskBuilds.appName, kioskBuilds.platform, kioskBuilds.version], + set: { ...build, publishedAt: new Date() }, + }) + .returning(); + return row!; +} + +export async function setBuildPinned(id: number, pinned: boolean): Promise { + await db.update(kioskBuilds).set({ pinned }).where(eq(kioskBuilds.id, id)); +} + +export async function deleteBuild(id: number): Promise { + const [row] = await db.delete(kioskBuilds).where(eq(kioskBuilds.id, id)).returning(); + return row; +} + +// ── The per-user key ── + +const hash = (key: string) => createHash('sha256').update(key).digest('hex'); + +/** + * The caller's key, minted on first ask. + * + * Lazy because there is no hook for a plugin to run when an account is created — `provisionOsAccount` is a + * fixed list — so first use of the plugin is when the key comes into existence. That is invisible to the + * user and needs no provisioning change. + */ +export async function getOrCreateUserKey(userId: number): Promise { + const [existing] = await db.select().from(kioskUserKeys).where(eq(kioskUserKeys.userId, userId)); + if (existing) return decryptSecret(PURPOSE, existing.keyEncrypted); + + const key = `kio_${randomBytes(24).toString('hex')}`; + await db.insert(kioskUserKeys).values({ + userId, + keyHash: hash(key), + keyEncrypted: encryptSecret(PURPOSE, key), + }); + return key; +} + +/** A new key; the old one stops working immediately. Cheap because the app never persists it. */ +export async function rotateUserKey(userId: number): Promise { + const key = `kio_${randomBytes(24).toString('hex')}`; + await db + .insert(kioskUserKeys) + .values({ userId, keyHash: hash(key), keyEncrypted: encryptSecret(PURPOSE, key) }) + .onConflictDoUpdate({ + target: kioskUserKeys.userId, + set: { keyHash: hash(key), keyEncrypted: encryptSecret(PURPOSE, key), rotatedAt: new Date() }, + }); + return key; +} + +/** + * Whose key this is, or null. + * + * Compared by HASH, so the lookup never needs the stored ciphertext and a database dump hands over nothing + * usable. This is the function every public artefact route will call once those exist. + */ +export async function userIdForKey(key: string): Promise { + const [row] = await db.select().from(kioskUserKeys).where(eq(kioskUserKeys.keyHash, hash(key))); + if (!row) return null; + // Debounced to a minute, the way `api_keys` does it: an install range-requests a 20MB file and would + // otherwise write on every chunk. + const last = row.lastUsedAt?.getTime() ?? 0; + if (Date.now() - last > 60_000) { + await db.update(kioskUserKeys).set({ lastUsedAt: new Date() }).where(eq(kioskUserKeys.id, row.id)); + } + return row.userId; +} + +// ── Devices ── + +export async function listDevices(userId?: number): Promise { + const q = db.select().from(kioskDevices).orderBy(desc(kioskDevices.createdAt)); + return userId === undefined ? q : q.where(eq(kioskDevices.userId, userId)); +} + +export async function getDevice(id: number): Promise { + const [row] = await db.select().from(kioskDevices).where(eq(kioskDevices.id, id)); + return row; +} + +/** Mint a pending device. The label is captured BEFORE the device speaks — see the schema note. */ +export async function createDevice(userId: number, label: string): Promise { + const [row] = await db.insert(kioskDevices).values({ userId, label }).returning(); + return row!; +} + +export async function updateDevice(id: number, patch: Partial): Promise { + await db.update(kioskDevices).set(patch).where(eq(kioskDevices.id, id)); +} + +export async function deleteDevice(id: number): Promise { + await db.delete(kioskDevices).where(eq(kioskDevices.id, id)); +} + +// ── Installs ── + +/** + * Best-effort by construction: an `itms-services://` install is handed to the OS and the server never learns + * how it went, so this is reported by the app. An absent row means "not reported", never "not installed". + */ +export async function recordInstall(userId: number, buildId: number, deviceId?: number): Promise { + await db.insert(kioskInstalls).values({ userId, buildId, deviceId: deviceId ?? null }); +} diff --git a/db/types.ts b/db/types.ts new file mode 100644 index 0000000..c0afe8e --- /dev/null +++ b/db/types.ts @@ -0,0 +1,21 @@ +import type * as KioskSchema from './schema'; + +/** A published build, as stored. `profileExpiresAt` is the date it stops launching — see the schema. */ +export type KioskBuild = typeof KioskSchema.kioskBuilds.$inferSelect; +export type KioskBuildInsert = typeof KioskSchema.kioskBuilds.$inferInsert; + +export type KioskDevice = typeof KioskSchema.kioskDevices.$inferSelect; +export type KioskDeviceInsert = typeof KioskSchema.kioskDevices.$inferInsert; + +/** + * One app as the catalogue presents it: its newest build per platform. + * + * The mobile app opens with this, and the shape is deliberately flat — it decides for itself what is + * installed (URL schemes) and at which version (the shared keychain), so the server does not model that. + */ +export type KioskApp = { + appName: string; + displayName: string; + ios: KioskBuild | null; + android: KioskBuild | null; +}; diff --git a/web/AppsPanel.tsx b/web/AppsPanel.tsx new file mode 100644 index 0000000..fe4b033 --- /dev/null +++ b/web/AppsPanel.tsx @@ -0,0 +1,103 @@ +import { useQuery } from '@tanstack/react-query'; +import { Download, Loader2, Smartphone } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import type { KioskApp, KioskBuild } from '../db/types'; + +/* + The install list. This is the page that has to work on a phone in mobile Safari — see `layout.ts` for why + it is first in a horizontal root. + + Deliberately plain: rows and a button. Somebody is holding a phone, and the whole content is "which apps + exist and which one do I want". +*/ + +const prettyVersion = (v: string) => + v.length === 10 ? `${v.slice(0, 4)}.${v.slice(4, 6)}.${v.slice(6, 8)}.${v.slice(8)}` : v; + +const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`; + +const InstallButton = ({ build }: { build: KioskBuild }) => { + const client = useClient(); + + // The link is fetched at the moment of the tap, never rendered ahead of time: it embeds the caller's key, + // and the key is re-read on demand precisely so rotating one costs nothing. + const install = async () => { + const { url } = await client.get<{ url: string; kind: 'itms' | 'apk' }>( + `/kiosk/install/${encodeURIComponent(build.appName)}/${build.platform}/${encodeURIComponent(build.version)}`, + ); + // `itms-services://` is handed to the OS, which takes over from here. There is no completion callback — + // the app reports its own installs afterwards. + window.location.href = url; + }; + + return ( + + ); +}; + +const BuildRow = ({ build }: { build: KioskBuild }) => ( +
+
+
{build.displayName}
+
+ {build.platform} + {prettyVersion(build.version)} + {build.shortVersion} + {mb(build.sizeBytes)} + {build.signing ? {build.signing} : null} + {/* The date the app stops launching. Absent for an unsigned build, which has no profile. */} + {build.profileExpiresAt ? ( + + expires {new Date(build.profileExpiresAt).toISOString().slice(0, 10)} + + ) : null} +
+
+ +
+); + +export const AppsPanel = () => { + const client = useClient(); + const { data, isLoading } = useQuery({ + queryKey: ['kiosk', 'apps'], + queryFn: () => client.get<{ apps: KioskApp[] }>('/kiosk/apps'), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const apps = data?.apps ?? []; + if (!apps.length) { + return ( +
+ + No builds published yet. + Builds are published from the machine that signs them. +
+ ); + } + + return ( +
+ {apps.map((app) => ( +
+ {app.ios ? : null} + {app.android ? : null} +
+ ))} +
+ ); +}; diff --git a/web/DevicesPanel.tsx b/web/DevicesPanel.tsx new file mode 100644 index 0000000..9484425 --- /dev/null +++ b/web/DevicesPanel.tsx @@ -0,0 +1,93 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Loader2, Tablet, Trash2 } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import type { KioskDevice } from '../db/types'; + +/* + Registered devices — yours, or everyone's if you are the owner. The server decides which; this panel does + not ask, so a member cannot request somebody else's list by omitting a parameter. + + Enrolment itself is not here. Capturing a UDID needs a `.mobileconfig` served without a session and a + callback the device posts to, and neither can exist until the platform can serve a plugin's route above + the account gate. Until then this shows what the standalone service registered. +*/ + +const STATUS_TONE: Record = { + registered: 'bg-success/10 text-success', + captured: 'bg-info/10 text-info', + pending: 'bg-muted text-muted-foreground', + failed: 'bg-destructive/10 text-destructive', +}; + +export const DevicesPanel = () => { + const client = useClient(); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['kiosk', 'devices'], + queryFn: () => client.get<{ devices: KioskDevice[] }>('/kiosk/devices'), + }); + + const remove = useMutation({ + mutationFn: (id: number) => client.delete(`/kiosk/devices/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['kiosk', 'devices'] }), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const devices = data?.devices ?? []; + + return ( +
+
+ Devices +
+ + {!devices.length ? ( +
+ + No devices registered. + + A device is registered by installing an enrolment profile, which is not wired up yet. + +
+ ) : ( + devices.map((device) => ( +
+
+
{device.label}
+
+ + {device.status} + + {device.product ? {device.product} : null} + {device.osVersion ? iOS {device.osVersion} : null} + {/* The UDID is deliberately not shown in full: it identifies a person's hardware, and the + whole reason artefacts stay private is that a `.ipa` leaks the full list of them. */} + {device.udid ? …{device.udid.slice(-6)} : null} + {device.failureReason ? ( + {device.failureReason} + ) : null} +
+
+ +
+ )) + )} +
+ ); +}; diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..0541ec9 --- /dev/null +++ b/web/layout.ts @@ -0,0 +1,21 @@ +import type { LayoutNode } from 'officerdev'; + +/* + Horizontal root, app list first — and that is load-bearing rather than aesthetic. + + On a phone, `WorkspaceRenderer` collapses a HORIZONTAL group to one child at a time and defaults to + `children[0]`. A vertical group is not collapsed at all: every panel stacks into the viewport. And + `PluginScreen` passes no `mobilePanelId`, so whatever is first is the only panel a phone will ever show. + + That matters more here than anywhere else in the platform, because the install page is the one page that + MUST work in mobile Safari — you cannot install the Kiosk app from the Kiosk app. +*/ +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'kiosk-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'kiosk-apps', appType: 'kiosk-apps' }, size: 55 }, + { node: { type: 'panel', id: 'kiosk-devices', appType: 'kiosk-devices' }, size: 45 }, + ], +}; diff --git a/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..19bef66 --- /dev/null +++ b/web/panels.ts @@ -0,0 +1,14 @@ +import type { AppRegistryMeta } from 'officerdev'; +import { Smartphone, Tablet } from 'lucide-react'; +import { AppsPanel } from './AppsPanel'; +import { DevicesPanel } from './DevicesPanel'; + +// `panels.ts`, not `.tsx` — discovery checks for that exact filename and refuses the plugin by name if it +// is missing, so components live in their own files and this stays a declaration. +// +// `availableOnPanel: false` throughout: PluginScreen renders the workspace `locked`, so these are not +// swappable and offering them in a picker would be a lie. +export const appRegistryMetas: AppRegistryMeta[] = [ + { key: 'kiosk-apps', name: 'Apps', icon: Smartphone, component: AppsPanel, availableOnPanel: false }, + { key: 'kiosk-devices', name: 'Devices', icon: Tablet, component: DevicesPanel, availableOnPanel: false }, +];