kiosk: the authenticated API and two panels
what the mobile app needs is now buildable against something real: GET /apps, GET /key, GET /install/:app/:platform/:version, POST /installs, plus device and build verbs. all under /api/kiosk, all behind a platform session. the install URL is built per request and never stored, because it carries the CALLING user's key - two people asking for the same build get different links, which is the point: the artefact route is public and the key is what authorises it. deliberately NOT here: the four requests that arrive with no session. installd fetching the manifest and the ipa, the device posting its udid, safari downloading a mobileconfig. a plugin cannot serve an unauthenticated route today, so those wait for the platform primitive and will land in api/public.ts - a separate file so the two surfaces cannot be confused by whoever reads either one. so /install returns a correct URL pointing at a route that does not exist yet. that is the shape the app should build against. layout is a horizontal root with the app list first, and that is load-bearing: on a phone WorkspaceRenderer collapses horizontal groups to children[0] and PluginScreen passes no mobilePanelId, so first is the only panel a phone shows. the install page is the one page that must work in mobile safari. PUBLIC_URL is read from the environment rather than officer-url.mjs, which exports only loopback URLs - ios is not on loopback, and it needs https with a cert it already trusts or it declines silently.
This commit is contained in:
@@ -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.
|
||||
+190
@@ -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<string, KioskApp>();
|
||||
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<KioskBuild>;
|
||||
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<typeof upsertBuild>[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 });
|
||||
});
|
||||
@@ -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/<app>/<version>/`. 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' };
|
||||
}
|
||||
+162
@@ -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<KioskBuild[]> {
|
||||
const rows = await db.execute<KioskBuild>(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<KioskBuild[]> {
|
||||
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<KioskBuild | undefined> {
|
||||
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<KioskBuild | undefined> {
|
||||
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<KioskBuild> {
|
||||
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<void> {
|
||||
await db.update(kioskBuilds).set({ pinned }).where(eq(kioskBuilds.id, id));
|
||||
}
|
||||
|
||||
export async function deleteBuild(id: number): Promise<KioskBuild | undefined> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<number | null> {
|
||||
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<KioskDevice[]> {
|
||||
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<KioskDevice | undefined> {
|
||||
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<KioskDevice> {
|
||||
const [row] = await db.insert(kioskDevices).values({ userId, label }).returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function updateDevice(id: number, patch: Partial<KioskDevice>): Promise<void> {
|
||||
await db.update(kioskDevices).set(patch).where(eq(kioskDevices.id, id));
|
||||
}
|
||||
|
||||
export async function deleteDevice(id: number): Promise<void> {
|
||||
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<void> {
|
||||
await db.insert(kioskInstalls).values({ userId, buildId, deviceId: deviceId ?? null });
|
||||
}
|
||||
+21
@@ -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;
|
||||
};
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void install()}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Install
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const BuildRow = ({ build }: { build: KioskBuild }) => (
|
||||
<div className="flex items-center gap-3 border-b border-border/60 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{build.displayName}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-medium uppercase">{build.platform}</span>
|
||||
<span className="tabular-nums">{prettyVersion(build.version)}</span>
|
||||
<span>{build.shortVersion}</span>
|
||||
<span>{mb(build.sizeBytes)}</span>
|
||||
{build.signing ? <span>{build.signing}</span> : null}
|
||||
{/* The date the app stops launching. Absent for an unsigned build, which has no profile. */}
|
||||
{build.profileExpiresAt ? (
|
||||
<span title="The provisioning profile expires — the app stops launching then">
|
||||
expires {new Date(build.profileExpiresAt).toISOString().slice(0, 10)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<InstallButton build={build} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AppsPanel = () => {
|
||||
const client = useClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['kiosk', 'apps'],
|
||||
queryFn: () => client.get<{ apps: KioskApp[] }>('/kiosk/apps'),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const apps = data?.apps ?? [];
|
||||
if (!apps.length) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm text-muted-foreground">
|
||||
<Smartphone className="h-8 w-8" />
|
||||
<span>No builds published yet.</span>
|
||||
<span className="text-xs">Builds are published from the machine that signs them.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{apps.map((app) => (
|
||||
<div key={app.appName}>
|
||||
{app.ios ? <BuildRow build={app.ios} /> : null}
|
||||
{app.android ? <BuildRow build={app.android} /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const devices = data?.devices ?? [];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 border-b border-border bg-muted px-4 py-2 text-xs font-semibold">
|
||||
Devices
|
||||
</div>
|
||||
|
||||
{!devices.length ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
<Tablet className="h-7 w-7" />
|
||||
<span>No devices registered.</span>
|
||||
<span className="text-xs">
|
||||
A device is registered by installing an enrolment profile, which is not wired up yet.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<div key={device.id} className="group flex items-center gap-3 border-b border-border/60 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{device.label}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span className={`rounded px-1.5 py-0.5 font-medium ${STATUS_TONE[device.status] ?? 'bg-muted'}`}>
|
||||
{device.status}
|
||||
</span>
|
||||
{device.product ? <span>{device.product}</span> : null}
|
||||
{device.osVersion ? <span>iOS {device.osVersion}</span> : 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 ? <span className="tabular-nums">…{device.udid.slice(-6)}</span> : null}
|
||||
{device.failureReason ? (
|
||||
<span className="text-destructive">{device.failureReason}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate(device.id)}
|
||||
title="Remove device"
|
||||
aria-label={`Remove ${device.label}`}
|
||||
className="shrink-0 cursor-pointer rounded p-1.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
];
|
||||
Reference in New Issue
Block a user