P4.6: install pages with accurate iOS install modes
The human install surface (subplan 04 Slice 5), honest per PLAN.md's iOS Install Policy. App gains ios_mode (artifact_only default / testflight / enterprise) and testflight_url; absent db.json members load as defaults. dist app set is the admin mutator (apps are created by publish), enforcing the policy preconditions as machine-readable errors: testflight requires --testflight-url, enterprise requires --ios-bundle-id. Every set appends an app.update audit event. GET /install/<slug>/<channel> renders the channel's current release as platform sections — the User-Agent's platform ordered first and marked — with per-mode iOS actions: TestFlight link, itms-services OTA deep link, or an IPA download explicitly labeled as not installable from the page. Size + sha256 are visible on every artifact row. GET /install/<slug>/<channel>/manifest.plist serves the enterprise OTA manifest (bundle id, version, https package URL off the Host header) and 404s in any other mode. The index links channels to their pages. KNOWN sx BOUNDARY (issue 0098): an enum literal returned directly into an optional target silently lowers to variant 0 — ua_platform routes every variant through a typed local. make test 19/19 (new: server_install.sx pinned acceptance).
This commit is contained in:
@@ -12,6 +12,10 @@
|
||||
// GET /api/apps/<slug> {"app":..,"releases":[..],"channels":[..]}
|
||||
// GET /download/<sha256> the object's bytes (application/octet-stream,
|
||||
// X-Checksum-SHA256 header)
|
||||
// GET /install/<slug>/<channel> install page (UA-aware,
|
||||
// iOS actions honest per the app's IosMode)
|
||||
// GET /install/<slug>/<channel>/manifest.plist enterprise OTA manifest
|
||||
// (404 unless ios_mode is enterprise)
|
||||
//
|
||||
// Writes require `Authorization: Bearer <token>` with the `publish` scope
|
||||
// (src/server/auth.sx; tokens are minted with `dist token create`):
|
||||
@@ -256,13 +260,14 @@ render_index :: (repo: *Repo) -> string {
|
||||
app := repo.apps.items[a];
|
||||
page = concat(page, concat("<h2>", concat(html_escape(app.slug), concat(" <small>", concat(html_escape(app.display_name), "</small></h2>")))));
|
||||
|
||||
// Channels: name -> current release pointer.
|
||||
// Channels: name (linked to the install page) -> current release.
|
||||
page = concat(page, "<table><tr><th>channel</th><th>current release</th></tr>");
|
||||
c := 0;
|
||||
while c < repo.channels.len {
|
||||
ch := repo.channels.items[c];
|
||||
if ch.app_id == app.id {
|
||||
page = concat(page, concat("<tr><td>", concat(html_escape(ch.name), concat("</td><td>", concat(html_escape(ch.current_release_id), "</td></tr>")))));
|
||||
link := concat("<a href=\"/install/", concat(html_escape(app.slug), concat("/", concat(html_escape(ch.name), concat("\">", concat(html_escape(ch.name), "</a>"))))));
|
||||
page = concat(page, concat("<tr><td>", concat(link, concat("</td><td>", concat(html_escape(ch.current_release_id), "</td></tr>")))));
|
||||
}
|
||||
c += 1;
|
||||
}
|
||||
@@ -351,6 +356,276 @@ handle_download :: (client: i32, store_dir: string, sha: string) {
|
||||
http.respond(client, 200, "application/octet-stream", extra, bq!);
|
||||
}
|
||||
|
||||
// ── install pages (subplan 04 Slice 5) ────────────────────────────────
|
||||
|
||||
// True iff `needle` occurs in `hay` (UA sniffing; both are short).
|
||||
contains :: (hay: string, needle: string) -> bool {
|
||||
if needle.len == 0 { return true; }
|
||||
if needle.len > hay.len { return false; }
|
||||
i := 0;
|
||||
while i + needle.len <= hay.len {
|
||||
j := 0;
|
||||
ok := true;
|
||||
while j < needle.len {
|
||||
if hay[i + j] != needle[j] { ok = false; break; }
|
||||
j += 1;
|
||||
}
|
||||
if ok { return true; }
|
||||
i += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The requesting device's platform, sniffed from the User-Agent. Order
|
||||
// matters: iPhone UAs contain "like Mac OS X" and Android UAs contain
|
||||
// "Linux", so the mobile checks run first. Null = no idea.
|
||||
// Each variant goes through a TYPED LOCAL: an enum literal returned
|
||||
// directly into the `?Platform` target silently lowers to variant 0
|
||||
// (sx issue 0098).
|
||||
ua_platform :: (ua: string) -> ?Platform {
|
||||
p : Platform = .ios;
|
||||
if contains(ua, "iPhone") or contains(ua, "iPad") or contains(ua, "iPod") { return p; }
|
||||
if contains(ua, "Android") { p = .android_apk; return p; }
|
||||
if contains(ua, "Macintosh") { p = .macos; return p; }
|
||||
if contains(ua, "Windows") { p = .windows; return p; }
|
||||
if contains(ua, "Linux") { p = .linux; return p; }
|
||||
return null;
|
||||
}
|
||||
|
||||
platform_label :: (p: Platform) -> string {
|
||||
if p == .ios { return "iOS"; }
|
||||
if p == .android_apk { return "Android"; }
|
||||
if p == .macos { return "macOS"; }
|
||||
if p == .linux { return "Linux"; }
|
||||
return "Windows";
|
||||
}
|
||||
|
||||
// The release the (app, channel) pair currently serves, resolving each
|
||||
// step or answering the precise 404. Null = response already sent.
|
||||
// `host` (the request's Host header) feeds the absolute URLs the
|
||||
// enterprise install flow needs.
|
||||
InstallCtx :: struct {
|
||||
app: App;
|
||||
channel_name: string;
|
||||
release: Release;
|
||||
host: string = "localhost";
|
||||
}
|
||||
|
||||
resolve_install :: (client: i32, repo: *Repo, slug: string, chan_name: string) -> ?InstallCtx {
|
||||
aq := repo.find_app_by_slug(slug);
|
||||
if aq == null {
|
||||
respond_error(client, 404, "install.unknown_app",
|
||||
concat("no app with that slug in the store: ", slug));
|
||||
return null;
|
||||
}
|
||||
app := aq!;
|
||||
cq := repo.get_channel(app.id, chan_name);
|
||||
if cq == null {
|
||||
respond_error(client, 404, "install.unknown_channel",
|
||||
concat("the app has no channel with that name: ", chan_name));
|
||||
return null;
|
||||
}
|
||||
rq := repo.get_release(cq!.current_release_id);
|
||||
if rq == null {
|
||||
respond_error(client, 404, "install.no_release",
|
||||
concat("the channel does not point at a published release: ", chan_name));
|
||||
return null;
|
||||
}
|
||||
return InstallCtx.{ app = app, channel_name = chan_name, release = rq! };
|
||||
}
|
||||
|
||||
// One artifact row: filename (download link), size, full sha256 — the
|
||||
// digest is part of the contract, not decoration.
|
||||
install_artifact_row :: (art: Artifact, label: string) -> string {
|
||||
row := concat("<p class=\"artifact\"><a href=\"/download/", concat(art.sha256, "\">"));
|
||||
row = concat(row, concat(html_escape(art.filename), "</a>"));
|
||||
if label.len > 0 { row = concat(row, concat(" <small>", concat(label, "</small>"))); }
|
||||
row = concat(row, concat(" <small>", concat(int_to_string(art.size_bytes), " bytes</small>")));
|
||||
row = concat(row, concat("<br><small class=\"sha\">sha256 ", concat(art.sha256, "</small></p>")));
|
||||
return row;
|
||||
}
|
||||
|
||||
// The iOS section body for the app's install mode — the honest version
|
||||
// of "install on iPhone" (see IosMode in the domain).
|
||||
render_ios_actions :: (ctx: *InstallCtx, art: Artifact) -> string {
|
||||
app := ctx.app;
|
||||
if app.ios_mode == .testflight {
|
||||
s := concat("<p><a class=\"action\" href=\"", concat(html_escape(app.testflight_url), "\">Open in TestFlight</a></p>"));
|
||||
s = concat(s, "<p><small>Installation runs through Apple's TestFlight flow.</small></p>");
|
||||
return concat(s, install_artifact_row(art, "(IPA artifact)"));
|
||||
}
|
||||
if app.ios_mode == .enterprise {
|
||||
plist := concat("https://", concat(ctx.host, concat("/install/", concat(app.slug, concat("/", concat(ctx.channel_name, "/manifest.plist"))))));
|
||||
itms := concat("itms-services://?action=download-manifest&url=", plist);
|
||||
s := concat("<p><a class=\"action\" href=\"", concat(html_escape(itms), "\">Install on enrolled device</a></p>"));
|
||||
s = concat(s, "<p><small>Over-the-air enterprise install; requires MDM enrollment and HTTPS.</small></p>");
|
||||
return concat(s, install_artifact_row(art, "(IPA artifact)"));
|
||||
}
|
||||
// artifact_only — a download, never an install action
|
||||
s := install_artifact_row(art, "");
|
||||
return concat(s, "<p><small>IPA artifact download only — it cannot be installed on an iPhone or iPad from this page.</small></p>");
|
||||
}
|
||||
|
||||
INSTALL_HEAD :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>install</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:40rem;padding:0 1rem}h1{font-size:1.1rem}h2{font-size:1rem;margin-top:1.6rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}a.action{display:inline-block;border:1px solid #7ab7ff;border-radius:4px;padding:.4rem .8rem;margin:.2rem 0}small,.dim{color:#737b8c}.sha{word-break:break-all}.detected h2{color:#9fe09f}</style></head><body>";
|
||||
|
||||
// The install page: the channel's current release, one section per
|
||||
// platform artifact, the requester's platform first and marked.
|
||||
render_install :: (ctx: *InstallCtx, repo: *Repo, detected: ?Platform) -> string {
|
||||
app := ctx.app;
|
||||
rel := ctx.release;
|
||||
|
||||
page := INSTALL_HEAD;
|
||||
page = concat(page, concat("<h1>", concat(html_escape(app.display_name), concat(" <small>", concat(html_escape(rel.version), concat(" · ", concat(html_escape(ctx.channel_name), "</small></h1>")))))));
|
||||
|
||||
// platform sections in fixed order — except the detected platform,
|
||||
// which moves to the front
|
||||
order : [5]Platform = .[ .ios, .android_apk, .macos, .linux, .windows ];
|
||||
if detected != null {
|
||||
d := detected!;
|
||||
order[0] = d;
|
||||
slot := 1;
|
||||
j := 0;
|
||||
while j < 5 {
|
||||
cand : Platform = .ios;
|
||||
if j == 1 { cand = .android_apk; }
|
||||
if j == 2 { cand = .macos; }
|
||||
if j == 3 { cand = .linux; }
|
||||
if j == 4 { cand = .windows; }
|
||||
if cand != d {
|
||||
order[slot] = cand;
|
||||
slot += 1;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
k := 0;
|
||||
while k < 5 {
|
||||
p := order[k];
|
||||
// the release's artifact for this platform, if any
|
||||
ai := 0;
|
||||
while ai < repo.artifacts.len {
|
||||
art := repo.artifacts.items[ai];
|
||||
if art.release_id == rel.id and art.platform == p {
|
||||
marked := detected != null and k == 0;
|
||||
if marked { page = concat(page, "<section class=\"detected\">"); }
|
||||
else { page = concat(page, "<section>"); }
|
||||
page = concat(page, concat("<h2>", platform_label(p)));
|
||||
if marked { page = concat(page, " <small>your device</small>"); }
|
||||
page = concat(page, "</h2>");
|
||||
if p == .ios {
|
||||
page = concat(page, render_ios_actions(ctx, art));
|
||||
} else {
|
||||
label := "";
|
||||
if p == .android_apk { label = "(APK)"; }
|
||||
page = concat(page, install_artifact_row(art, label));
|
||||
}
|
||||
page = concat(page, "</section>");
|
||||
}
|
||||
ai += 1;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
|
||||
page = concat(page, "<p class=\"dim\"><small><a href=\"/\">← all apps</a></small></p>");
|
||||
return concat(page, "</body></html>");
|
||||
}
|
||||
|
||||
handle_install_page :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
ctxq := resolve_install(client, @repo, slug, chan_name);
|
||||
if ctxq == null { return; }
|
||||
ctx := ctxq!;
|
||||
|
||||
ua := "";
|
||||
uq := http.header_value(req.headers, "user-agent");
|
||||
if uq != null { ua = uq!; }
|
||||
hostq := http.header_value(req.headers, "host");
|
||||
if hostq != null { ctx.host = hostq!; }
|
||||
|
||||
http.respond(client, 200, "text/html; charset=utf-8", "",
|
||||
render_install(@ctx, @repo, ua_platform(ua)));
|
||||
}
|
||||
|
||||
// The enterprise OTA manifest: Apple's plist shape, with the software
|
||||
// package URL pointing back at this server over HTTPS (itms-services
|
||||
// refuses plain http; TLS terminates at the reverse proxy).
|
||||
handle_manifest_plist :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
ctxq := resolve_install(client, @repo, slug, chan_name);
|
||||
if ctxq == null { return; }
|
||||
ctx := ctxq!;
|
||||
|
||||
if ctx.app.ios_mode != .enterprise {
|
||||
respond_error(client, 404, "install.not_enterprise",
|
||||
"the app's iOS install mode is not enterprise; no OTA manifest exists");
|
||||
return;
|
||||
}
|
||||
bid := bundle_id_for(ctx.app, .ios);
|
||||
if bid.len == 0 {
|
||||
respond_error(client, 404, "install.no_bundle_id",
|
||||
"the app has no iOS bundle id; set one with: dist app set --ios-bundle-id");
|
||||
return;
|
||||
}
|
||||
// the release's ios artifact
|
||||
found := false;
|
||||
art : Artifact = .{};
|
||||
ai := 0;
|
||||
while ai < repo.artifacts.len {
|
||||
a := repo.artifacts.items[ai];
|
||||
if a.release_id == ctx.release.id and a.platform == .ios { art = a; found = true; break; }
|
||||
ai += 1;
|
||||
}
|
||||
if !found {
|
||||
respond_error(client, 404, "install.no_ios_artifact",
|
||||
"the channel's current release carries no iOS artifact");
|
||||
return;
|
||||
}
|
||||
|
||||
host := "localhost";
|
||||
hostq := http.header_value(req.headers, "host");
|
||||
if hostq != null { host = hostq!; }
|
||||
url := concat("https://", concat(host, concat("/download/", art.sha256)));
|
||||
|
||||
x := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict><key>items</key><array><dict>";
|
||||
x = concat(x, "<key>assets</key><array><dict><key>kind</key><string>software-package</string><key>url</key><string>");
|
||||
x = concat(x, html_escape(url));
|
||||
x = concat(x, "</string></dict></array><key>metadata</key><dict><key>bundle-identifier</key><string>");
|
||||
x = concat(x, html_escape(bid));
|
||||
x = concat(x, "</string><key>bundle-version</key><string>");
|
||||
x = concat(x, html_escape(ctx.release.version));
|
||||
x = concat(x, "</string><key>kind</key><string>software</string><key>title</key><string>");
|
||||
x = concat(x, html_escape(ctx.app.display_name));
|
||||
x = concat(x, "</string></dict></dict></array></dict></plist>\n");
|
||||
|
||||
http.respond(client, 200, "application/xml", "", x);
|
||||
}
|
||||
|
||||
// GET /install/<slug>/<channel>[/manifest.plist]
|
||||
handle_install_route :: (client: i32, store_dir: string, req: *http.Request, tail: string) {
|
||||
s1 := seg_split(tail);
|
||||
if s1.head.len == 0 or s1.rest.len == 0 {
|
||||
respond_error(client, 404, "http.not_found",
|
||||
"install pages live at /install/<slug>/<channel>");
|
||||
return;
|
||||
}
|
||||
s2 := seg_split(s1.rest);
|
||||
if s2.rest.len == 0 {
|
||||
handle_install_page(client, store_dir, req, s1.head, s2.head);
|
||||
return;
|
||||
}
|
||||
if s2.rest == "manifest.plist" {
|
||||
handle_manifest_plist(client, store_dir, req, s1.head, s2.head);
|
||||
return;
|
||||
}
|
||||
respond_error(client, 404, "http.not_found",
|
||||
concat("no install route for ", tail));
|
||||
}
|
||||
|
||||
// ── write surface (POST, token-gated) ─────────────────────────────────
|
||||
|
||||
// `s` split at its first '/': head before it, rest after it ("" when no
|
||||
@@ -703,6 +978,10 @@ route :: (client: i32, store_dir: string, req: *http.Request) -> i64 {
|
||||
handle_download(client, store_dir, tail_after(path, "/download/"));
|
||||
return 200;
|
||||
}
|
||||
if starts_with(path, "/install/") {
|
||||
handle_install_route(client, store_dir, req, tail_after(path, "/install/"));
|
||||
return 200;
|
||||
}
|
||||
respond_error(client, 404, "http.not_found",
|
||||
concat("no route for ", path));
|
||||
return 404;
|
||||
|
||||
Reference in New Issue
Block a user