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.
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
};
|