day-cli 0.3.0

Declarative app development API using native UI toolkits
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
// Copyright © The Daybrite Project
// SPDX-License-Identifier: MPL-2.0

//! ios-uikit → App Store .ipa via `xcodebuild archive` + `-exportArchive` (arm64-only device
//! build; automatic signing with an App Store Connect API key — the Tauri/Flutter CI path).
//! Without `signing.ios` config this degrades LOUDLY to an UNSIGNED device .ipa
//! (`-unsigned.ipa`): a real `-sdk iphoneos` Release build with code signing disabled,
//! packaged as `Payload/<App>.app`. It cannot launch as-is — the developer signs it
//! (codesign / Xcode's Devices window) or sideloads it with AltStore/SideStore, which re-sign
//! every binary with the user's own Apple ID anyway (§16.5).

use std::path::{Path, PathBuf};
use std::process::Command;

use super::settings::{PackOptions, resolve_degradable};
use super::{Artifact, PackError, SignTier, run_tool};
use crate::meta::Project;
use crate::ops::status;
use crate::targets::Target;

pub fn pack(
    project: &Project,
    target: &'static Target,
    opts: &PackOptions,
    dist: &Path,
) -> Result<Artifact, PackError> {
    let ios = project
        .manifest
        .signing
        .as_ref()
        .and_then(|s| s.ios.as_ref());
    if opts.no_sign || ios.is_none() {
        if ios.is_none() {
            status(
                "Warning",
                "no signing.ios config — packing an UNSIGNED device .ipa (sideload it with \
                 AltStore/SideStore or sign it yourself; a signed .ipa needs signing.ios: \
                 {team, key-id, issuer, key-path})",
            );
        }
        return unsigned_ipa(project, target, opts, dist);
    }
    let ios = ios.unwrap();

    // A team that references an unset secret degrades to the unsigned device .ipa (§20).
    let Some(team) = resolve_degradable(&ios.team, "signing.ios.team").map_err(PackError::Sign)?
    else {
        status(
            "Warning",
            "signing.ios.team unresolved — packing an UNSIGNED device .ipa instead of a signed one",
        );
        return unsigned_ipa(project, target, opts, dist);
    };
    let method = ios
        .export_method
        .clone()
        .unwrap_or_else(|| "app-store-connect".into());
    // ASC API key: all-or-nothing triple; without it xcodebuild uses the local Xcode account session.
    let key_id = resolve_field(ios.key_id.as_ref(), "signing.ios.key-id")?;
    let issuer = resolve_field(ios.issuer.as_ref(), "signing.ios.issuer")?;
    let key_path = resolve_field(ios.key_path.as_ref(), "signing.ios.key-path")?;
    let asc = match (key_id, issuer, key_path) {
        (Some(k), Some(i), Some(p)) => {
            if !Path::new(&p).exists() {
                return Err(PackError::Sign(format!("ASC key file not found: {p}")));
            }
            Some((k, i, p))
        }
        (None, None, None) => None,
        _ => {
            return Err(PackError::Sign(
                "signing.ios: key-id, issuer and key-path must be set together".into(),
            ));
        }
    };
    // Without the ASC key, Automatic signing leans on the local Xcode account session — which
    // exists on a developer's Mac and never on a CI runner, where the archive can only end in
    // "No Accounts". A resolved team with an unresolved key trio on CI is therefore not a
    // signing configuration, it is half of one: degrade to the unsigned device .ipa the same
    // loud way every other unresolved signing input does (§20), instead of failing the tag
    // build inside xcodebuild.
    if asc.is_none() && std::env::var("CI").is_ok_and(|v| !v.is_empty() && v != "false") {
        status(
            "Warning",
            "signing.ios.team is set but key-id/issuer/key-path are not, and this is CI (no \
             Xcode account session for Automatic signing) — packing an UNSIGNED device .ipa",
        );
        return unsigned_ipa(project, target, opts, dist);
    }

    let name = &project.manifest.app.name;
    let version = &project.manifest.app.version;
    let title = project
        .manifest
        .app
        .title
        .clone()
        .unwrap_or_else(|| name.clone());

    // The DayPieces SwiftPM package must exist before xcodebuild resolves the project.
    let floor = crate::mobile::prepare_ios(project).map_err(PackError::Other)?;
    ensure_shared_scheme(project).map_err(PackError::Other)?;

    let build_dir = project.root.join("build/day/ios-uikit");
    let archive = build_dir.join(format!("{title}.xcarchive"));
    let _ = std::fs::remove_dir_all(&archive);
    let day_bin = std::env::current_exe().map_err(|e| PackError::Other(e.to_string()))?;

    // --- archive (device, Release, automatic signing) -----------------------
    status(
        "Building",
        "ios-uikit (xcodebuild archive, generic/platform=iOS)",
    );
    let mut cmd = Command::new("xcodebuild");
    crate::ops::apply_determinism(&mut cmd);
    cmd.current_dir(project.root.join("platform/ios"))
        .args(["-project", "DayApp.xcodeproj", "-scheme", "Runner"])
        .args(["-configuration", "Release"])
        .args(["-destination", "generic/platform=iOS"])
        .arg("-archivePath")
        .arg(&archive)
        .arg("-derivedDataPath")
        .arg(build_dir.join("archive-dd"))
        .arg("-allowProvisioningUpdates")
        // The scaffold pbxproj disables signing for simulator development — the archive build
        // re-enables it from the command line (command-line settings override the project).
        .arg("CODE_SIGNING_ALLOWED=YES")
        .arg("CODE_SIGN_STYLE=Automatic")
        .arg("CODE_SIGN_IDENTITY=Apple Development")
        .arg(format!("DEVELOPMENT_TEAM={team}"))
        .arg(format!("MARKETING_VERSION={version}"))
        .arg(format!(
            "CURRENT_PROJECT_VERSION={}",
            project.manifest.app.build
        ))
        .arg(format!("DAY_BIN={}", day_bin.display()))
        // `archive` already implies DEPLOYMENT_POSTPROCESSING, but state it so the signed and
        // unsigned lanes ship the same shape of binary.
        .args(REPRODUCIBLE_BUILD_SETTINGS);
    if let Some(f) = &floor {
        cmd.arg(format!("IPHONEOS_DEPLOYMENT_TARGET={f}"));
    }
    cmd.arg("archive");
    if let Some((k, i, p)) = &asc {
        cmd.args(["-authenticationKeyID", k])
            .args(["-authenticationKeyIssuerID", i])
            .arg("-authenticationKeyPath")
            .arg(std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p)));
    }
    run_tool(&mut cmd, "xcodebuild archive").map_err(PackError::Sign)?;

    // --- export (.ipa) -------------------------------------------------------
    let export_plist = build_dir.join("ExportOptions.plist");
    std::fs::write(&export_plist, export_options(&method, &team))
        .map_err(|e| PackError::Other(e.to_string()))?;
    let export_dir = build_dir.join("export");
    let _ = std::fs::remove_dir_all(&export_dir);
    status("Packing", &format!("xcodebuild -exportArchive ({method})"));
    let mut cmd = Command::new("xcodebuild");
    crate::ops::apply_determinism(&mut cmd);
    cmd.current_dir(project.root.join("platform/ios"))
        .arg("-exportArchive")
        .arg("-archivePath")
        .arg(&archive)
        .arg("-exportPath")
        .arg(&export_dir)
        .arg("-exportOptionsPlist")
        .arg(&export_plist)
        .arg("-allowProvisioningUpdates");
    if let Some((k, i, p)) = &asc {
        cmd.args(["-authenticationKeyID", k])
            .args(["-authenticationKeyIssuerID", i])
            .arg("-authenticationKeyPath")
            .arg(std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p)));
    }
    run_tool(&mut cmd, "xcodebuild -exportArchive").map_err(PackError::Sign)?;

    let ipa = std::fs::read_dir(&export_dir)
        .map_err(|e| PackError::Other(e.to_string()))?
        .flatten()
        .map(|e| e.path())
        .find(|p| p.extension().and_then(|x| x.to_str()) == Some("ipa"))
        .ok_or_else(|| {
            PackError::Other(format!("no .ipa exported under {}", export_dir.display()))
        })?;
    let out = dist.join(super::naming::artifact_file(
        project,
        target,
        opts,
        &[],
        "ipa",
    ));
    std::fs::copy(&ipa, &out).map_err(|e| PackError::Other(e.to_string()))?;
    Ok(Artifact {
        path: out,
        kind: "ipa",
        sha256: String::new(),
        tier: SignTier::Release,
    })
}

/// A configured-but-optional signing field: absent config stays None; an unset secret degrades.
fn resolve_field(raw: Option<&String>, what: &str) -> Result<Option<String>, PackError> {
    match raw {
        None => Ok(None),
        Some(r) => resolve_degradable(r, what).map_err(PackError::Sign),
    }
}

/// ExportOptions for automatic signing; Xcode ≥15.4 names ("app-store-connect", "release-testing").
pub(crate) fn export_options(method: &str, team: &str) -> String {
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>method</key><string>{method}</string>
  <key>teamID</key><string>{team}</string>
  <key>signingStyle</key><string>automatic</string>
  <key>uploadSymbols</key><true/>
  <key>destination</key><string>export</string>
</dict></plist>
"#
    )
}

/// `xcodebuild archive` needs a scheme (targets aren't archivable). The scaffold ships none — the
/// pbxproj carries stable synthetic ids, so generate a shared Runner scheme on demand, parsing the
/// native-target id and product name out of the pbxproj.
fn ensure_shared_scheme(project: &Project) -> Result<(), String> {
    let xcodeproj = project.root.join("platform/ios/DayApp.xcodeproj");
    let scheme = xcodeproj.join("xcshareddata/xcschemes/Runner.xcscheme");
    if scheme.exists() {
        return Ok(());
    }
    let pbxproj = std::fs::read_to_string(xcodeproj.join("project.pbxproj"))
        .map_err(|e| format!("read pbxproj: {e}"))?;
    let target_id =
        find_native_target_id(&pbxproj).ok_or("no PBXNativeTarget found in project.pbxproj")?;
    let product = pbxproj
        .lines()
        .find(|l| l.contains("explicitFileType = wrapper.application"))
        .and_then(|l| l.split("path = ").nth(1))
        .and_then(|s| s.split(';').next())
        .map(str::trim)
        .ok_or("no application product reference in project.pbxproj")?;
    std::fs::create_dir_all(scheme.parent().unwrap()).map_err(|e| e.to_string())?;
    let xml = format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1500" version="1.7">
  <BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
    <BuildActionEntries>
      <BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
        <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{target_id}" BuildableName="{product}" BlueprintName="Runner" ReferencedContainer="container:DayApp.xcodeproj"/>
      </BuildActionEntry>
    </BuildActionEntries>
  </BuildAction>
  <ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
  <LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
    <BuildableProductRunnable runnableDebuggingMode="0">
      <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{target_id}" BuildableName="{product}" BlueprintName="Runner" ReferencedContainer="container:DayApp.xcodeproj"/>
    </BuildableProductRunnable>
  </LaunchAction>
</Scheme>
"#
    );
    std::fs::write(&scheme, xml).map_err(|e| e.to_string())?;
    status("Generated", &format!("{}", scheme.display()));
    Ok(())
}

/// The 24-hex-digit object id of the first PBXNativeTarget (`XXXX /* Name */ = {` whose next line
/// declares `isa = PBXNativeTarget`).
fn find_native_target_id(pbxproj: &str) -> Option<String> {
    let mut lines = pbxproj.lines().peekable();
    while let Some(line) = lines.next() {
        let t = line.trim();
        if let Some(rest) = t.strip_suffix("= {")
            && let Some(id) = rest.split_whitespace().next()
            && id.len() == 24
            && id.chars().all(|c| c.is_ascii_hexdigit())
            && lines
                .peek()
                .is_some_and(|n| n.trim() == "isa = PBXNativeTarget;")
        {
            return Some(id.to_string());
        }
    }
    None
}

/// Build settings that keep the shipped iOS binary reproducible (DESIGN.md §20.3).
///
/// Without these, `ld` leaves a debug map in the linked Mach-O: one `N_OSO` stab per object file,
/// each holding that `.o`'s ABSOLUTE path under `SYMROOT`. `SYMROOT` derives from the project root,
/// so the same commit built in two different directories yields two different binaries — 267
/// differing entries for the showcase app. Stripping the debug map removes them (and ~700 KB).
///
/// Xcode runs `dsymutil` before `strip`, so the `.dSYM` is still produced and crash symbolication
/// is unaffected — the debug info moves out of the shipped binary rather than being discarded.
/// `STRIP_STYLE=debugging` keeps the dynamic symbol table intact, so backtraces still resolve
/// exported frames.
/// The second half is the ObjC selector stubs, and it fixes a different failure. Xcode 14 added a
/// size optimization where the compiler emits `_objc_msgSend$<selector>` references and the LINKER
/// synthesizes an `__objc_stubs` section for them. That leaves the binary with TWO `__got` slots for
/// `_objc_msgSend` — one for the classic `__stubs` path, one for `__objc_stubs` — and which
/// consumer gets which slot is not stable: two CI builds of the same commit differed in exactly
/// those 404 bytes, every `__objc_stubs` entry pointing at slot 1528 in one and 1536 in the other,
/// with byte-identical GOT contents. The binaries were equivalent; the linker just flipped a coin.
/// Turning the optimization off leaves one slot, so there is no coin to flip.
///
/// Both flags are needed — `OTHER_CFLAGS` alone was measured to leave the section in place, because
/// the references come from Swift here, not from ObjC sources. For this app the flag also makes the
/// binary ~9.7 KB SMALLER: the stub table is overhead when little of the code is ObjC.
const REPRODUCIBLE_BUILD_SETTINGS: [&str; 5] = [
    "DEPLOYMENT_POSTPROCESSING=YES",
    "STRIP_INSTALLED_PRODUCT=YES",
    "STRIP_STYLE=debugging",
    "OTHER_CFLAGS=$(inherited) -fno-objc-msgsend-selector-stubs",
    "OTHER_SWIFT_FLAGS=$(inherited) -Xcc -fno-objc-msgsend-selector-stubs",
];

/// The unsigned fallback: a real DEVICE build (`-sdk iphoneos`, Release) with code signing
/// disabled, packaged as `Payload/<App>.app` inside a `-unsigned.ipa`. It cannot launch until
/// signed — AltStore/SideStore re-sign it with the user's own Apple ID on install, or the
/// developer signs it directly (codesign / Xcode's Devices window).
fn unsigned_ipa(
    project: &Project,
    target: &'static Target,
    opts: &PackOptions,
    dist: &Path,
) -> Result<Artifact, PackError> {
    let version = &project.manifest.app.version;

    // The same pre-build staging the signed path (and build_ios) performs.
    let floor = crate::mobile::prepare_ios(project).map_err(PackError::Other)?;

    let build_dir = project.root.join("build/day/ios-uikit");
    let symroot = build_dir.join("pack-unsigned");
    let day_bin = std::env::current_exe().map_err(|e| PackError::Other(e.to_string()))?;
    status("Building", "ios-uikit (xcodebuild, iphoneos, unsigned)");
    let mut cmd = Command::new("xcodebuild");
    crate::ops::apply_determinism(&mut cmd);
    cmd.current_dir(project.root.join("platform/ios"))
        .args(["-project", "DayApp.xcodeproj", "-target", "Runner"])
        .args(["-configuration", "Release", "-sdk", "iphoneos"])
        .args(["-arch", "arm64"])
        .arg(format!("SYMROOT={}", symroot.display()))
        .arg("CODE_SIGNING_ALLOWED=NO")
        .arg("CODE_SIGNING_REQUIRED=NO")
        .arg(format!("MARKETING_VERSION={version}"))
        .arg(format!(
            "CURRENT_PROJECT_VERSION={}",
            project.manifest.app.build
        ))
        .arg(format!("DAY_BIN={}", day_bin.display()))
        .args(REPRODUCIBLE_BUILD_SETTINGS);
    if let Some(f) = &floor {
        cmd.arg(format!("IPHONEOS_DEPLOYMENT_TARGET={f}"));
    }
    run_tool(cmd.arg("build"), "xcodebuild (iphoneos, unsigned)").map_err(PackError::Other)?;

    let products = symroot.join("Release-iphoneos");
    let app = std::fs::read_dir(&products)
        .map_err(|e| PackError::Other(format!("reading {}: {e}", products.display())))?
        .flatten()
        .map(|e| e.path())
        .find(|p| p.extension().and_then(|x| x.to_str()) == Some("app"))
        .ok_or_else(|| PackError::Other(format!("no .app bundle in {}", products.display())))?;

    // .ipa layout: a zip whose root holds Payload/<App>.app.
    let staging = build_dir.join("ipa-staging");
    let payload = staging.join("Payload");
    let _ = std::fs::remove_dir_all(&staging);
    std::fs::create_dir_all(&payload).map_err(|e| PackError::Other(e.to_string()))?;
    run_tool(
        Command::new("ditto")
            .arg(&app)
            .arg(payload.join(app.file_name().unwrap_or_default())),
        "ditto stage",
    )
    .map_err(PackError::Other)?;
    // The `unsigned` token survives into the release name; release CI strips it so the
    // published asset keeps ONE name whether or not the run had signing material.
    let out = dist.join(super::naming::artifact_file(
        project,
        target,
        opts,
        &["unsigned"],
        "ipa",
    ));
    let _ = std::fs::remove_file(&out);
    super::normalize_mtimes(&staging).map_err(PackError::Other)?;
    run_tool(
        Command::new("ditto")
            .args(["-c", "-k"])
            .arg(&staging)
            .arg(&out),
        "ditto zip",
    )
    .map_err(PackError::Other)?;
    Ok(Artifact {
        path: out,
        kind: "ipa-unsigned",
        sha256: String::new(),
        tier: SignTier::Unsigned,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn export_options_plist_shape() {
        let plist = export_options("app-store-connect", "TEAM123");
        assert!(plist.contains("<key>method</key><string>app-store-connect</string>"));
        assert!(plist.contains("<key>teamID</key><string>TEAM123</string>"));
        assert!(plist.contains("<key>signingStyle</key><string>automatic</string>"));
    }

    #[test]
    fn native_target_id_from_pbxproj() {
        let pbx = "\t\tDA0000000000000000000020 /* Runner */ = {\n\t\t\tisa = PBXNativeTarget;\n";
        assert_eq!(
            find_native_target_id(pbx).as_deref(),
            Some("DA0000000000000000000020")
        );
        assert_eq!(find_native_target_id("nothing here"), None);
    }
}