Skip to main content

waterui_cli/apple/
toolchain.rs

1//! Apple toolchain module
2
3use std::convert::Infallible;
4use std::ffi::OsString;
5
6use eyre::Context as _;
7use serde::{Deserialize, Serialize};
8
9use crate::toolchain::{Host, Toolchain, ToolchainError};
10
11/// Represents the complete Apple toolchain consisting of Xcode and an Apple SDK
12pub type AppleToolchain = (Xcode, AppleSdk);
13
14/// Represents the Xcode toolchain
15#[derive(Debug, Clone, Default)]
16pub struct Xcode;
17
18impl Toolchain for Xcode {
19    type Installation = Infallible;
20    async fn check(
21        &self,
22        host: &Host,
23    ) -> Result<(), crate::toolchain::ToolchainError<Self::Installation>> {
24        // Check if Xcode is installed and available
25        if host.which("xcodebuild").await.is_ok() && host.which("xcode-select").await.is_ok() {
26            Ok(())
27        } else {
28            Err(ToolchainError::unfixable(
29                "Xcode is not installed or not found in PATH",
30                "Please install Xcode from the App Store or the Apple Developer website and ensure it's available in your PATH.",
31            ))
32        }
33    }
34}
35
36/// Represents an Apple SDK (e.g., iOS, macOS)
37#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
38pub enum AppleSdk {
39    /// iOS SDK
40    #[serde(rename = "iOS")]
41    Ios,
42    /// iOS Simulator SDK
43    #[serde(rename = "iOS Simulator")]
44    IosSimulator,
45    /// macOS SDK
46    #[serde(rename = "macOS")]
47    Macos,
48    /// tvOS SDK
49    #[serde(rename = "tvOS")]
50    TvOs,
51    /// watchOS SDK
52    #[serde(rename = "watchOS")]
53    WatchOs,
54    /// visionOS SDK
55    #[serde(rename = "visionOS")]
56    VisionOs,
57}
58
59impl AppleSdk {
60    /// Get the SDK name as used by `xcrun`
61    #[must_use]
62    pub const fn sdk_name(&self) -> &str {
63        match self {
64            Self::Ios => "iphoneos",
65            Self::IosSimulator => "iphonesimulator",
66            Self::Macos => "macosx",
67            Self::TvOs => "appletvos",
68            Self::WatchOs => "watchos",
69            Self::VisionOs => "xros",
70        }
71    }
72}
73
74/// The development team used to sign device builds.
75///
76/// Physical-device builds must be signed in every profile — iOS refuses
77/// unsigned code outright — so `DEVELOPMENT_TEAM` cannot come from the Xcode
78/// project (which does not know the developer's team) and is resolved here
79/// instead. Preference order:
80///
81/// 1. The team Xcode last provisioned with
82///    (`IDEProvisioningTeamManagerLastSelectedTeamID`), then any other team
83///    Xcode knows an account for (`IDEProvisioningTeamByIdentifier`). A
84///    signed-in account can mint both the provisioning profile and the
85///    "Apple Development" certificate it needs, so these work even when the
86///    keychain holds no matching identity yet.
87/// 2. The team embedded in a keychain development certificate —
88///    `security find-identity -v -p codesigning` prints identities as
89///    `… "Apple Development: Liu Yuhao (6C5VGHHJ59)"` where the parenthesized
90///    suffix is the team ID. Such a team is only usable when a matching
91///    profile is already installed locally; without an Xcode account the
92///    portal cannot mint one, which is why it ranks last.
93///
94/// # Errors
95/// Fails when neither an Xcode account team nor a development certificate
96/// exists — the error tells the user where to add an account.
97pub async fn development_team_id(host: &Host) -> eyre::Result<String> {
98    if let Some(team) = xcode_account_team(host).await {
99        return Ok(team);
100    }
101    let output = host
102        .output("security", ["find-identity", "-v", "-p", "codesigning"])
103        .await
104        .wrap_err("failed to run `security find-identity`")?;
105    let stdout = String::from_utf8_lossy(&output.stdout);
106    parse_development_team(&stdout).ok_or_else(|| {
107        eyre::eyre!(
108            "No signing team found. Physical iOS builds must be signed: open \
109             Xcode → Settings → Accounts and sign in an Apple ID (a free \
110             account is enough), then re-run `water run`."
111        )
112    })
113}
114
115/// A team Xcode can provision for: the account's last-selected team, or any
116/// team its accounts advertise. Reads Xcode's account registry from
117/// `~/Library/Preferences/com.apple.dt.Xcode.plist`; a missing Xcode install
118/// or unsigned-in state yields `None`.
119async fn xcode_account_team(host: &Host) -> Option<String> {
120    let plist = host
121        .home_dir()?
122        .join("Library/Preferences/com.apple.dt.Xcode.plist");
123
124    let extract = |key: &str, format: &str| {
125        let plist = plist.clone();
126        let key = key.to_string();
127        let format = format.to_string();
128        async move {
129            host.output(
130                "plutil",
131                [
132                    OsString::from("-extract"),
133                    OsString::from(key),
134                    OsString::from(format),
135                    OsString::from("-o"),
136                    OsString::from("-"),
137                    plist.into_os_string(),
138                ],
139            )
140            .await
141        }
142    };
143
144    if let Ok(output) = extract("IDEProvisioningTeamManagerLastSelectedTeamID", "raw").await
145        && output.status.success()
146    {
147        let team = String::from_utf8_lossy(&output.stdout).trim().to_string();
148        if !team.is_empty() {
149            return Some(team);
150        }
151    }
152
153    let output = extract("IDEProvisioningTeamByIdentifier", "json")
154        .await
155        .ok()?;
156    if !output.status.success() {
157        return None;
158    }
159    let teams: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
160    teams.as_object()?.keys().next().cloned()
161}
162
163/// Extract the team ID from the first development identity in
164/// `security find-identity -v -p codesigning` output.
165fn parse_development_team(output: &str) -> Option<String> {
166    for line in output.lines() {
167        let is_development = line.contains("Apple Development:")
168            || line.contains("iPhone Developer:")
169            || line.contains("iOS Development:");
170        if !is_development {
171            continue;
172        }
173        if let Some(start) = line.rfind('(')
174            && let Some(end) = line.rfind(')')
175            && end > start
176        {
177            return Some(line[start + 1..end].to_string());
178        }
179    }
180    None
181}
182
183impl std::fmt::Display for AppleSdk {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        // Avoid panics in Display; serde is for config IO, not formatting.
186        match self {
187            Self::Ios => "iOS",
188            Self::IosSimulator => "iOS Simulator",
189            Self::Macos => "macOS",
190            Self::TvOs => "tvOS",
191            Self::WatchOs => "watchOS",
192            Self::VisionOs => "visionOS",
193        }
194        .fmt(f)
195    }
196}
197
198impl Toolchain for AppleSdk {
199    type Installation = Infallible;
200    async fn check(
201        &self,
202        host: &Host,
203    ) -> Result<(), crate::toolchain::ToolchainError<Self::Installation>> {
204        // Check if the required Apple SDK is available
205        let result = host
206            .run("xcrun", ["--sdk", self.sdk_name(), "--show-sdk-path"])
207            .await;
208
209        if result.is_err() {
210            return Err(ToolchainError::unfixable(
211                format!("{self} SDK is not installed or not available"),
212                format!(
213                    "Please install {self} SDK through Xcode or use xcode-select to configure the active developer directory."
214                ),
215            ));
216        }
217
218        Ok(())
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::{AppleSdk, Xcode};
225    use crate::toolchain::testing::TestMachine;
226    use crate::toolchain::{Toolchain, ToolchainError};
227
228    #[test]
229    fn xcode_ok_when_tools_on_path() {
230        let machine = TestMachine::new();
231        machine.install("xcodebuild");
232        machine.install("xcode-select");
233        let host = machine.host(Vec::<(String, String)>::new());
234        smol::block_on(Xcode.check(&host)).expect("xcodebuild + xcode-select on PATH must be ok");
235    }
236
237    #[test]
238    fn xcode_missing_is_unfixable() {
239        let machine = TestMachine::new();
240        let host = machine.host(Vec::<(String, String)>::new());
241        let result = smol::block_on(Xcode.check(&host));
242        assert!(
243            matches!(result, Err(ToolchainError::Unfixable(_))),
244            "Xcode requires a manual App Store install: {result:?}"
245        );
246    }
247
248    #[test]
249    fn apple_sdk_ok_when_xcrun_reports_path() {
250        let machine = TestMachine::new();
251        machine.install("xcrun");
252        machine.respond("XCRUN_SDK_PATH", "/fake/SDKs/iPhoneOS.sdk\n");
253        let host = machine.host(Vec::<(String, String)>::new());
254        smol::block_on(AppleSdk::Ios.check(&host))
255            .expect("an SDK path from xcrun must satisfy the check");
256    }
257
258    #[test]
259    fn apple_sdk_missing_is_unfixable() {
260        let machine = TestMachine::new();
261        machine.install("xcrun");
262        let host = machine.host(Vec::<(String, String)>::new());
263        let result = smol::block_on(AppleSdk::Ios.check(&host));
264        assert!(
265            matches!(result, Err(ToolchainError::Unfixable(_))),
266            "xcrun without an SDK path must be unfixable: {result:?}"
267        );
268    }
269}