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:
agra
2026-06-12 11:49:14 +03:00
parent 0a6fa65c58
commit aea3d62b60
7 changed files with 802 additions and 4 deletions

183
src/app/ops.sx Normal file
View File

@@ -0,0 +1,183 @@
// =====================================================================
// ops.sx (app) — `dist app set`: admin mutation of an app's install
// policy over the persisted store (P4.6, subplan 04 Slice 5).
//
// Apps are CREATED by publish (find-or-create on slug); `app set` only
// edits ones that exist. It updates display_name, the iOS install mode
// (artifact_only / testflight / enterprise), the TestFlight URL, and the
// iOS bundle id, then revalidates the whole App — so the iOS policy
// preconditions (testflight needs a URL, enterprise needs a bundle id)
// abort the set as loud, machine-readable errors instead of leaving a
// policy the install page cannot honor.
//
// FAILURE CONTRACT (as everywhere): every abort happens before `db.save`,
// so a failed set never changes db.json.
// =====================================================================
#import "modules/std.sx";
#import "modules/std/fs.sx";
#import "modules/std/json.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
#import "../domain/artifact.sx";
#import "../domain/channel.sx";
#import "../domain/token.sx";
#import "../domain/audit.sx";
#import "../domain/validate.sx";
#import "../repo/repo.sx";
db :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
pl :: #import "../publish/publish.sx";
AppOpError :: error {
Load, // db.json absent or unreadable
NotFound, // no app with that slug
Invalid, // the requested policy fails validation
Persist, // db.json could not be re-written
}
// What `dist app set` wants to change; empty string = leave unchanged.
AppSetRequest :: struct {
display_name: string = "";
ios_mode: string = ""; // variant name, parsed here
testflight_url: string = "";
ios_bundle_id: string = "";
}
AppSetOutcome :: struct {
app_id: string;
slug: string;
display_name: string;
ios_mode: string;
testflight_url: string;
ios_bundle_id: string;
}
app_invalid_code :: (e: ValidationErr) -> string {
if e == error.BadIosMode { return "app.bad_ios_mode"; }
if e == error.TestflightUrlRequired { return "app.testflight_url_required"; }
if e == error.BundleIdRequired { return "app.bundle_id_required"; }
return "app.invalid";
}
app_invalid_message :: (e: ValidationErr) -> string {
if e == error.BadIosMode { return "--ios-mode must be one of: artifact_only testflight enterprise"; }
if e == error.TestflightUrlRequired { return "ios_mode testflight requires --testflight-url"; }
if e == error.BundleIdRequired { return "ios_mode enterprise requires --ios-bundle-id"; }
return "app fails domain validation";
}
run_app_set :: (store_dir: string, slug: string, req: AppSetRequest, fail_out: *jout.CliFailure) -> (AppSetOutcome, !AppOpError) {
if !exists(path_join(store_dir, "db.json")) {
fail_out.code = "store.load";
fail_out.message = concat("no db.json under the store (nothing published yet): ", store_dir);
raise error.Load;
}
repo, le := db.load(store_dir);
if le {
fail_out.code = "store.load";
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
raise error.Load;
}
aq := repo.find_app_by_slug(slug);
if aq == null {
fail_out.code = "app.unknown";
fail_out.message = concat("no app with that slug in the store (publish creates apps): ", slug);
raise error.NotFound;
}
a := aq!;
if req.display_name.len > 0 { a.display_name = req.display_name; }
if req.ios_mode.len > 0 {
m, me := parse_ios_mode(req.ios_mode);
if me {
fail_out.code = app_invalid_code(me);
fail_out.message = app_invalid_message(me);
raise error.Invalid;
}
a.ios_mode = m;
}
if req.testflight_url.len > 0 { a.testflight_url = req.testflight_url; }
if req.ios_bundle_id.len > 0 {
// replace-or-add the iOS bundle id (one per platform)
found := false;
i := 0;
while i < a.bundle_ids.len {
if a.bundle_ids.items[i].platform == .ios {
a.bundle_ids.items[i].value = req.ios_bundle_id;
found = true;
}
i += 1;
}
if !found {
a.bundle_ids.append(BundleId.{ platform = .ios, value = req.ios_bundle_id },
repo.own_allocator);
}
}
a.updated_at = pl.now_secs();
verr := false;
vcode := "";
vmsg := "";
validate_app(a) catch (e) {
verr = true;
vcode = app_invalid_code(e);
vmsg = app_invalid_message(e);
};
if verr {
fail_out.code = vcode;
fail_out.message = vmsg;
raise error.Invalid;
}
repo.update_app(a);
repo.create_audit_event(AuditEvent.{
id = concat(concat("evt-app-set-", a.id), concat("-", int_to_string(a.updated_at))),
actor = "cli", action = "app.update", target_type = "app",
target_id = a.id, metadata = "", created_at = a.updated_at,
});
werr := false;
db.save(@repo, store_dir) catch { werr = true; };
if werr {
fail_out.code = "persist.save";
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
raise error.Persist;
}
return AppSetOutcome.{
app_id = a.id, slug = a.slug, display_name = a.display_name,
ios_mode = db.ios_mode_str(a.ios_mode),
testflight_url = a.testflight_url,
ios_bundle_id = bundle_id_for(a, .ios),
};
}
// `{"status":"updated","app":{id,slug,display_name,ios_mode,
// testflight_url,ios_bundle_id}}`.
write_app_set_json :: (o: *AppSetOutcome, dst: []u8) -> (i64, !JsonError) {
gpa := GPA.init();
root : Object = .{};
root.put("status", .str("updated"), xx gpa);
ao : Object = .{};
ao.put("id", .str(o.app_id), xx gpa);
ao.put("slug", .str(o.slug), xx gpa);
ao.put("display_name", .str(o.display_name), xx gpa);
ao.put("ios_mode", .str(o.ios_mode), xx gpa);
ao.put("testflight_url", .str(o.testflight_url), xx gpa);
ao.put("ios_bundle_id", .str(o.ios_bundle_id), xx gpa);
root.put("app", .object(ao), xx gpa);
rootv : Value = .object(root);
n := try write_to_buffer(rootv, dst);
return n;
}
app_set_human :: (o: *AppSetOutcome) -> string {
s := concat("updated app ", concat(o.slug, concat(" (", concat(o.display_name, ")\n"))));
s = concat(s, concat(" ios_mode: ", o.ios_mode));
if o.testflight_url.len > 0 { s = concat(s, concat(" testflight: ", o.testflight_url)); }
if o.ios_bundle_id.len > 0 { s = concat(s, concat(" ios bundle id: ", o.ios_bundle_id)); }
return concat(s, "\n");
}

View File

@@ -36,6 +36,7 @@ pl :: #import "publish/publish.sx";
rem :: #import "publish/remote.sx";
ops :: #import "release/ops.sx";
tops :: #import "token/ops.sx";
aops :: #import "app/ops.sx";
srv :: #import "server/distd.sx";
// Direct stderr writer (fd 2), so human help/usage/progress never lands on
@@ -51,7 +52,7 @@ emit_human :: (s: string, json_mode: bool) {
if json_mode { eputs(s); } else { out(s); }
}
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> publish into a local store directory, OR:\n --server <url> publish against a running distd (http://<ipv4-or-localhost>:<port>)\n --token <secret> bearer token for --server (mint with: dist token create)\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n GET (public): / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n POST (Bearer token, publish scope): /api/upload, /api/apps/<slug>/releases,\n /api/apps/<slug>/channels/<name>/promote, /api/apps/<slug>/channels/<name>/rollback\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + db.json directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + db.json directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> publish into a local store directory, OR:\n --server <url> publish against a running distd (http://<ipv4-or-localhost>:<port>)\n --token <secret> bearer token for --server (mint with: dist token create)\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n GET (public): / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n POST (Bearer token, publish scope): /api/upload, /api/apps/<slug>/releases,\n /api/apps/<slug>/channels/<name>/promote, /api/apps/<slug>/channels/<name>/rollback\n app\n app set edit an existing app's display name / iOS install policy\n --app <slug> app to edit (apps are created by publish)\n --local-store <dir> local artifact store + db.json directory\n --display-name <s> new display name\n --ios-mode <m> artifact_only | testflight | enterprise\n --testflight-url <u> TestFlight link (required for testflight mode)\n --ios-bundle-id <id> iOS bundle identifier (required for enterprise mode)\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + db.json directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + db.json directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
// True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool {
@@ -327,6 +328,38 @@ handle_token_revoke :: (p: *Parsed, json_mode: bool) {
}
}
// `dist app set` — edit an existing app's display name and iOS install
// policy over the persisted store (P4.6). Rendering contract as above.
handle_app_set :: (p: *Parsed, json_mode: bool) {
req : aops.AppSetRequest = .{};
if p.is_set("display-name") { req.display_name = p.value_of("display-name"); }
if p.is_set("ios-mode") { req.ios_mode = p.value_of("ios-mode"); }
if p.is_set("testflight-url") { req.testflight_url = p.value_of("testflight-url"); }
if p.is_set("ios-bundle-id") { req.ios_bundle_id = p.value_of("ios-bundle-id"); }
fail : jout.CliFailure = .{};
o, e := aops.run_app_set(p.value_of("local-store"), p.value_of("app"), req, @fail);
if e {
report_failure("app set", fail, json_mode);
}
if !e {
if !json_mode {
out(aops.app_set_human(@o));
return;
}
eputs("dist: app set ok\n");
raw : [4096]u8 = ---;
werr := false;
n := aops.write_app_set_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
if werr {
eputs("dist: internal error: JSON serialization failed\n");
exit_command_failed();
}
out(string.{ ptr = @raw[0], len = n });
out("\n");
}
}
// Positive decimal seconds (a token lifetime); anything else (empty,
// non-digits, zero, absurd length) is null — a usage error at the call
// site.
@@ -370,6 +403,7 @@ dispatch :: (p: *Parsed, json_mode: bool) {
if p.group == "token" and p.command == "create" { handle_token_create(p, json_mode); return; }
if p.group == "token" and p.command == "list" { handle_token_list(p, json_mode); return; }
if p.group == "token" and p.command == "revoke" { handle_token_revoke(p, json_mode); return; }
if p.group == "app" and p.command == "set" { handle_app_set(p, json_mode); return; }
eputs("dist: internal error: unrouted command\n");
exit_usage();
}
@@ -436,6 +470,14 @@ main :: () -> ! {
FlagSpec.{ name = "id", takes_value = true, required = true },
FlagSpec.{ name = "local-store", takes_value = true, required = true },
];
app_set_flags : []FlagSpec = .[
FlagSpec.{ name = "app", takes_value = true, required = true },
FlagSpec.{ name = "local-store", takes_value = true, required = true },
FlagSpec.{ name = "display-name", takes_value = true, required = false },
FlagSpec.{ name = "ios-mode", takes_value = true, required = false },
FlagSpec.{ name = "testflight-url", takes_value = true, required = false },
FlagSpec.{ name = "ios-bundle-id", takes_value = true, required = false },
];
cmds : []Command = .[
Command.{ group = "ci", command = "publish", flags = publish_flags },
Command.{ group = "release", command = "promote", flags = promote_flags },
@@ -444,6 +486,7 @@ main :: () -> ! {
Command.{ group = "token", command = "create", flags = token_create_flags },
Command.{ group = "token", command = "list", flags = token_list_flags },
Command.{ group = "token", command = "revoke", flags = token_revoke_flags },
Command.{ group = "app", command = "set", flags = app_set_flags },
];
diag : Diag = .{};

View File

@@ -17,6 +17,21 @@ BundleId :: struct {
value: string;
}
// How this app's iOS artifact may be installed (PLAN.md "iOS Install
// Policy"). A normal iPhone cannot install an arbitrary IPA from a web
// page, so the install surface must never imply it can:
// artifact_only — the IPA is downloadable, presented as bytes, never as
// an on-device install action (the safe default);
// testflight — installation happens through Apple's TestFlight flow
// (requires `testflight_url`);
// enterprise — enrolled devices install over-the-air via a signed
// HTTPS manifest plist (requires an iOS bundle id).
IosMode :: enum u8 {
artifact_only;
testflight;
enterprise;
}
// A distributable application. `slug` is the URL-safe handle; `id` is the
// opaque primary key. Timestamps are unix epoch seconds.
App :: struct {
@@ -26,6 +41,18 @@ App :: struct {
bundle_ids: List(BundleId);
owner: string;
visibility: Visibility = .private;
ios_mode: IosMode = .artifact_only;
testflight_url: string = "";
created_at: i64;
updated_at: i64;
}
// The app's bundle identifier for `platform`, "" when none is set.
bundle_id_for :: (a: App, p: Platform) -> string {
i := 0;
while i < a.bundle_ids.len {
if a.bundle_ids.items[i].platform == p { return a.bundle_ids.items[i].value; }
i += 1;
}
return "";
}

View File

@@ -21,6 +21,9 @@ ValidationErr :: error {
BadDigest, // sha256 was not exactly 64 lowercase-hex chars
BadTokenName, // token name empty or outside [a-z0-9._-]
BadScope, // scopes empty or a word outside the known scope set
BadIosMode, // ios mode id did not name an IosMode variant
TestflightUrlRequired, // ios_mode testflight without a testflight_url
BundleIdRequired, // ios_mode enterprise without an ios bundle id
}
// ── Character classes (ASCII byte codes) ────────────────────────────────
@@ -125,15 +128,32 @@ parse_platform :: (s: string) -> (Platform, !ValidationErr) {
// ── Aggregate validators (required-field presence + field rules) ─────────
// Parse an iOS install-mode id into the IosMode enum. Accepted ids are
// exactly the variant names; anything else -> BadIosMode.
parse_ios_mode :: (s: string) -> (IosMode, !ValidationErr) {
if s == "artifact_only" { return .artifact_only; }
if s == "testflight" { return .testflight; }
if s == "enterprise" { return .enterprise; }
raise error.BadIosMode;
}
// App requires id, slug, display_name, owner; slug must also pass
// validate_slug. Presence is checked first, so an empty slug surfaces as
// MissingField and a present-but-malformed slug as BadSlug.
// MissingField and a present-but-malformed slug as BadSlug. The iOS
// install policy must be satisfiable: testflight needs a URL to send the
// user to, enterprise needs the bundle id the OTA manifest declares.
validate_app :: (a: App) -> !ValidationErr {
if a.id.len == 0 { raise error.MissingField; }
if a.slug.len == 0 { raise error.MissingField; }
if a.display_name.len == 0 { raise error.MissingField; }
if a.owner.len == 0 { raise error.MissingField; }
try validate_slug(a.slug);
if a.ios_mode == .testflight and a.testflight_url.len == 0 {
raise error.TestflightUrlRequired;
}
if a.ios_mode == .enterprise and bundle_id_for(a, .ios).len == 0 {
raise error.BundleIdRequired;
}
return;
}

View File

@@ -64,6 +64,11 @@ policy_str :: (p: RolloutPolicy) -> string {
if p == .manual { return "manual"; }
return "percentage";
}
ios_mode_str :: (m: IosMode) -> string {
if m == .testflight { return "testflight"; }
if m == .enterprise { return "enterprise"; }
return "artifact_only";
}
status_str :: (s: ValidationStatus) -> string {
if s == .pending { return "pending"; }
if s == .valid { return "valid"; }
@@ -87,6 +92,11 @@ parse_policy :: (s: string) -> (RolloutPolicy, !LoadErr) {
if s == "percentage" { return .percentage; }
raise error.BadShape;
}
ios_mode_from :: (s: string) -> (IosMode, !LoadErr) {
m, e := parse_ios_mode(s); // reuse the domain parser
if e { raise error.BadShape; }
return m;
}
parse_status :: (s: string) -> (ValidationStatus, !LoadErr) {
if s == "pending" { return .pending; }
if s == "valid" { return .valid; }
@@ -113,6 +123,8 @@ app_to_json :: (a: App, alloc: Allocator) -> Value {
o.put("bundle_ids", .array(bids), alloc);
o.put("owner", .str(a.owner), alloc);
o.put("visibility", .str(visibility_str(a.visibility)), alloc);
o.put("ios_mode", .str(ios_mode_str(a.ios_mode)), alloc);
o.put("testflight_url", .str(a.testflight_url), alloc);
o.put("created_at", .int_(a.created_at), alloc);
o.put("updated_at", .int_(a.updated_at), alloc);
return .object(o);
@@ -331,6 +343,21 @@ app_from_json :: (o: Object, alloc: Allocator) -> (App, !LoadErr) {
}
a.owner = try req_str(o, "owner", alloc);
a.visibility = try parse_visibility(try req_str_view(o, "visibility"));
// OPTIONAL members (absent in db.json files from before the iOS
// install policy landed): default artifact_only / "". Present members
// are held to full strictness.
imq := db_obj_find(o, "ios_mode");
if imq != null {
imv := imq!;
if imv != .str { raise error.BadShape; }
a.ios_mode = try ios_mode_from(imv.str);
}
tfq := db_obj_find(o, "testflight_url");
if tfq != null {
tfv := tfq!;
if tfv != .str { raise error.BadShape; }
a.testflight_url = db_dup_str(tfv.str, alloc);
}
a.created_at = try req_int(o, "created_at");
a.updated_at = try req_int(o, "updated_at");
return a;

View File

@@ -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&#39;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 &mdash; 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(" &middot; ", 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=\"/\">&larr; 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;