waterui_cli/apple/
toolchain.rs1use std::convert::Infallible;
4use std::ffi::OsString;
5
6use eyre::Context as _;
7use serde::{Deserialize, Serialize};
8
9use crate::toolchain::{Host, Toolchain, ToolchainError};
10
11pub type AppleToolchain = (Xcode, AppleSdk);
13
14#[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 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#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
38pub enum AppleSdk {
39 #[serde(rename = "iOS")]
41 Ios,
42 #[serde(rename = "iOS Simulator")]
44 IosSimulator,
45 #[serde(rename = "macOS")]
47 Macos,
48 #[serde(rename = "tvOS")]
50 TvOs,
51 #[serde(rename = "watchOS")]
53 WatchOs,
54 #[serde(rename = "visionOS")]
56 VisionOs,
57}
58
59impl AppleSdk {
60 #[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
74pub 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
115async 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
163fn 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 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 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}