From dbc8799157b1eeda44c7ab2cd4c1e37acdc904d0 Mon Sep 17 00:00:00 2001 From: proggeramlug Date: Sat, 13 Jun 2026 12:59:10 +0200 Subject: [PATCH 1/2] feat(publish): wire watchOS publishing + tvOS/watchOS App Store distribute watchOS had no publish path; this mirrors the tvOS wiring for watchOS and adds App Store Connect distribute to both tvOS and watchOS. - server_api: add tvos_distribute + watchos_{deployment_target, encryption_exempt,info_plist,distribute} to BuildManifest - config_types: add distribute to TvosConfig/WatchosConfig; entry to WatchosConfig - mod.rs: is_watchos flag; watchOS entry (src/main_watchos.ts) + bundle_id resolution; build_number gate; tvos/watchos distribute extraction + manifest population; target_display + summary - credentials: tvOS/watchOS appstore/testflight credential preflight; watchOS added to interactive target picker - preflight: classify watchOS as a gui app A standalone watchOS app must have its own unique bundle id, so the watchOS resolver does NOT fall back to the iOS bundle id, and appstore/testflight publishing hard-requires an explicit [watchos] bundle_id (App Store Connect rejects duplicates). --- .../src/commands/publish/config_types.rs | 7 + .../perry/src/commands/publish/credentials.rs | 68 +++++++- crates/perry/src/commands/publish/mod.rs | 147 ++++++++++++++---- .../perry/src/commands/publish/preflight.rs | 1 + .../perry/src/commands/publish/server_api.rs | 10 ++ 5 files changed, 202 insertions(+), 31 deletions(-) diff --git a/crates/perry/src/commands/publish/config_types.rs b/crates/perry/src/commands/publish/config_types.rs index d499c7325e..7e8de33427 100644 --- a/crates/perry/src/commands/publish/config_types.rs +++ b/crates/perry/src/commands/publish/config_types.rs @@ -168,11 +168,15 @@ pub(super) struct AndroidConfig { #[derive(Debug, Deserialize)] pub(super) struct WatchosConfig { pub(super) bundle_id: Option, + pub(super) entry: Option, pub(super) deployment_target: Option, pub(super) encryption_exempt: Option, pub(super) info_plist: Option>, pub(super) team_id: Option, pub(super) signing_identity: Option, + /// `appstore` / `testflight` — upload the signed watchOS app to App Store + /// Connect. A standalone watchOS app uploads exactly like iOS. + pub(super) distribute: Option, } // #854: deserialized [tvos] table; not every key is read. @@ -186,6 +190,9 @@ pub(super) struct TvosConfig { pub(super) info_plist: Option>, pub(super) team_id: Option, pub(super) signing_identity: Option, + /// `appstore` / `testflight` — upload the signed tvOS app to App Store + /// Connect. tvOS signs/packages exactly like iOS. + pub(super) distribute: Option, } #[derive(Debug, Deserialize)] diff --git a/crates/perry/src/commands/publish/credentials.rs b/crates/perry/src/commands/publish/credentials.rs index 415f33c50e..0c68d1a80b 100644 --- a/crates/perry/src/commands/publish/credentials.rs +++ b/crates/perry/src/commands/publish/credentials.rs @@ -2,13 +2,16 @@ use super::*; /// Prompt for target platform selection. pub(super) fn prompt_target(default: Option<&str>) -> String { - let options = &["macOS", "iOS", "visionOS", "tvOS", "Android", "Linux"]; + let options = &[ + "macOS", "iOS", "visionOS", "tvOS", "watchOS", "Android", "Linux", + ]; let default_idx = match default { Some("ios") => 1, Some("visionos") => 2, Some("tvos") => 3, - Some("android") => 4, - Some("linux") => 5, + Some("watchos") => 4, + Some("android") => 5, + Some("linux") => 6, _ => 0, }; let selection = Select::new() @@ -21,8 +24,9 @@ pub(super) fn prompt_target(default: Option<&str>) -> String { 1 => "ios".into(), 2 => "visionos".into(), 3 => "tvos".into(), - 4 => "android".into(), - 5 => "linux".into(), + 4 => "watchos".into(), + 5 => "android".into(), + 6 => "linux".into(), _ => "macos".into(), } } @@ -257,6 +261,10 @@ pub(super) fn validate_credentials_for_distribute( p8_key_content: Option<&str>, is_macos: bool, macos_distribute: Option<&str>, + is_tvos: bool, + tvos_distribute: Option<&str>, + is_watchos: bool, + watchos_distribute: Option<&str>, ) -> Result<()> { // Android + playstore if is_android { @@ -306,6 +314,56 @@ pub(super) fn validate_credentials_for_distribute( } } + // tvOS + appstore/testflight (signs/packages exactly like iOS) + if is_tvos { + let distribute = tvos_distribute.unwrap_or(""); + if distribute == "appstore" || distribute == "testflight" { + let mut missing = Vec::new(); + if apple_key_id.is_none() { + missing.push("Key ID (--apple-key-id / PERRY_APPLE_KEY_ID)"); + } + if apple_issuer_id.is_none() { + missing.push("Issuer ID (--apple-issuer-id / PERRY_APPLE_ISSUER_ID)"); + } + if p8_key_content.is_none() { + missing.push(".p8 key (--apple-p8-key / PERRY_APPLE_P8_KEY)"); + } + if !missing.is_empty() { + bail!( + "tvos.distribute = \"{distribute}\" requires App Store Connect API credentials.\n\ + Missing: {}\n\ + Run `perry setup tvos` or pass the missing flags.", + missing.join(", ") + ); + } + } + } + + // watchOS + appstore/testflight (standalone watch app, uploads like iOS) + if is_watchos { + let distribute = watchos_distribute.unwrap_or(""); + if distribute == "appstore" || distribute == "testflight" { + let mut missing = Vec::new(); + if apple_key_id.is_none() { + missing.push("Key ID (--apple-key-id / PERRY_APPLE_KEY_ID)"); + } + if apple_issuer_id.is_none() { + missing.push("Issuer ID (--apple-issuer-id / PERRY_APPLE_ISSUER_ID)"); + } + if p8_key_content.is_none() { + missing.push(".p8 key (--apple-p8-key / PERRY_APPLE_P8_KEY)"); + } + if !missing.is_empty() { + bail!( + "watchos.distribute = \"{distribute}\" requires App Store Connect API credentials.\n\ + Missing: {}\n\ + Run `perry setup watchos` or pass the missing flags.", + missing.join(", ") + ); + } + } + } + // macOS + appstore/notarize/both if is_macos { let distribute = macos_distribute.unwrap_or(""); diff --git a/crates/perry/src/commands/publish/mod.rs b/crates/perry/src/commands/publish/mod.rs index 4814f2b1f1..13fb0f4509 100644 --- a/crates/perry/src/commands/publish/mod.rs +++ b/crates/perry/src/commands/publish/mod.rs @@ -158,13 +158,14 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> } else if interactive { prompt_target(saved.default_target.as_deref()) } else { - bail!("No target specified. Use: perry publish "); + bail!("No target specified. Use: perry publish "); }; let target_display = match target_name.as_str() { "ios" => "iOS", "visionos" => "visionOS", "tvos" => "tvOS", + "watchos" => "watchOS", "android" => "Android", "linux" => "Linux", "windows" => "Windows", @@ -174,6 +175,7 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> let is_ios = target_name == "ios"; let is_visionos = target_name == "visionos"; let is_tvos = target_name == "tvos"; + let is_watchos = target_name == "watchos"; let is_android = target_name == "android"; let is_linux = target_name == "linux"; @@ -240,6 +242,14 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) .unwrap_or_else(|| "src/main_tvos.ts".into()) + } else if is_watchos { + config + .watchos + .as_ref() + .and_then(|w| w.entry.clone()) + .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) + .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) + .unwrap_or_else(|| "src/main_watchos.ts".into()) } else { config .app @@ -284,31 +294,38 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> // Auto-increment build_number for targets that need monotonic build numbers let is_windows = target_name == "windows"; let is_web = target_name == "web"; - let is_macos = - !is_ios && !is_visionos && !is_tvos && !is_android && !is_linux && !is_windows && !is_web; + let is_macos = !is_ios + && !is_visionos + && !is_tvos + && !is_watchos + && !is_android + && !is_linux + && !is_windows + && !is_web; let macos_needs_upload = is_macos && matches!(macos_distribute.as_deref(), Some("appstore") | Some("both")); - let build_number = if is_ios || is_visionos || is_tvos || is_android || macos_needs_upload { - let n = toml_build_number + 1; - if let Ok(content) = fs::read_to_string(&perry_toml_path) { - let updated = if content.contains("build_number =") { - content.replace( - &format!("build_number = {}", toml_build_number), - &format!("build_number = {}", n), - ) - } else { - // Insert build_number after the version line - content.replace( - &format!("version = \"{}\"", version), - &format!("version = \"{}\"\nbuild_number = {}", version, n), - ) - }; - fs::write(&perry_toml_path, &updated).ok(); - } - n - } else { - toml_build_number - }; + let build_number = + if is_ios || is_visionos || is_tvos || is_watchos || is_android || macos_needs_upload { + let n = toml_build_number + 1; + if let Ok(content) = fs::read_to_string(&perry_toml_path) { + let updated = if content.contains("build_number =") { + content.replace( + &format!("build_number = {}", toml_build_number), + &format!("build_number = {}", n), + ) + } else { + // Insert build_number after the version line + content.replace( + &format!("version = \"{}\"", version), + &format!("version = \"{}\"\nbuild_number = {}", version, n), + ) + }; + fs::write(&perry_toml_path, &updated).ok(); + } + n + } else { + toml_build_number + }; let app_bundle_id = config.app.as_ref().and_then(|a| a.bundle_id.clone()); let project_bundle_id = config.project.as_ref().and_then(|p| p.bundle_id.clone()); @@ -350,6 +367,17 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> .or_else(|| project_bundle_id.clone()) .or_else(|| config.ios.as_ref().and_then(|i| i.bundle_id.clone())) .unwrap_or_else(|| format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-"))) + } else if is_watchos { + // A standalone watchOS app must have its OWN unique bundle id — do NOT + // fall back to the iOS app's id (App Store Connect rejects duplicates). + // The appstore/testflight preflight below hard-requires [watchos] bundle_id. + config + .watchos + .as_ref() + .and_then(|w| w.bundle_id.clone()) + .or_else(|| app_bundle_id.clone()) + .or_else(|| project_bundle_id.clone()) + .unwrap_or_else(|| format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-"))) } else { config .macos @@ -472,6 +500,8 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> let visionos_distribute = config.visionos.as_ref().and_then(|i| i.distribute.clone()); let visionos_encryption_exempt = config.visionos.as_ref().and_then(|i| i.encryption_exempt); let visionos_info_plist = config.visionos.as_ref().and_then(|i| i.info_plist.clone()); + let tvos_distribute = config.tvos.as_ref().and_then(|t| t.distribute.clone()); + let watchos_distribute = config.watchos.as_ref().and_then(|w| w.distribute.clone()); let macos_encryption_exempt = config.macos.as_ref().and_then(|m| m.encryption_exempt); // Android-specific config from perry.toml @@ -1054,7 +1084,14 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> // Pre-flight credential validation — fail fast before building the tarball { - let is_macos = !is_android && !is_ios && !is_linux && !is_windows && !is_web; + let is_macos = !is_android + && !is_ios + && !is_visionos + && !is_tvos + && !is_watchos + && !is_linux + && !is_windows + && !is_web; validate_credentials_for_distribute( is_android, android_distribute.as_deref(), @@ -1066,9 +1103,36 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> p8_key_content.as_deref(), is_macos, macos_distribute.as_deref(), + is_tvos, + tvos_distribute.as_deref(), + is_watchos, + watchos_distribute.as_deref(), )?; } + // A standalone watchOS app uploaded to App Store Connect must have its own + // unique bundle id, distinct from any companion iOS app. Require it explicitly + // rather than silently inheriting (and colliding with) the iOS bundle id. + if is_watchos + && matches!( + watchos_distribute.as_deref(), + Some("appstore") | Some("testflight") + ) + && config + .watchos + .as_ref() + .and_then(|w| w.bundle_id.clone()) + .is_none() + { + bail!( + "watchos.distribute = \"{}\" requires an explicit [watchos] bundle_id.\n\ + A standalone watchOS app must have its own bundle id, distinct from your iOS app \ + (App Store Connect rejects duplicate bundle ids).\n\ + Run `perry setup watchos` or add `bundle_id = \"...\"` under [watchos] in perry.toml.", + watchos_distribute.as_deref().unwrap_or("appstore") + ); + } + // Pre-flight validation for iOS App Store / TestFlight — detect common rejection reasons if is_ios { ios_preflight_validation( @@ -1114,7 +1178,7 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> { println!(" Signing: Google Cloud KMS (EV code signing)"); } - } else if is_ios || is_macos { + } else if is_ios || is_macos || is_tvos || is_watchos { if let Some(ref id) = apple_identity { println!(" Signing: {id}"); } @@ -1128,6 +1192,17 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> ) { println!(" Distribute: App Store Connect (TestFlight)"); + } else if (is_tvos || is_watchos) + && matches!( + if is_tvos { + tvos_distribute.as_deref() + } else { + watchos_distribute.as_deref() + }, + Some("appstore") | Some("testflight") + ) + { + println!(" Distribute: App Store Connect (TestFlight)"); } else if is_macos { match macos_distribute.as_deref() { Some("both") => println!(" Distribute: App Store + Notarized DMG"), @@ -1271,6 +1346,26 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> } else { None }, + tvos_distribute: if is_tvos { tvos_distribute } else { None }, + watchos_deployment_target: if is_watchos { + config + .watchos + .as_ref() + .and_then(|w| w.deployment_target.clone()) + } else { + None + }, + watchos_encryption_exempt: if is_watchos { + config.watchos.as_ref().and_then(|w| w.encryption_exempt) + } else { + None + }, + watchos_info_plist: if is_watchos { + config.watchos.as_ref().and_then(|w| w.info_plist.clone()) + } else { + None + }, + watchos_distribute: if is_watchos { watchos_distribute } else { None }, android_min_sdk: if is_android { android_min_sdk } else { None }, android_target_sdk: if is_android { android_target_sdk } else { None }, android_permissions: if is_android { diff --git a/crates/perry/src/commands/publish/preflight.rs b/crates/perry/src/commands/publish/preflight.rs index 01048f6ecc..4cd1f552e9 100644 --- a/crates/perry/src/commands/publish/preflight.rs +++ b/crates/perry/src/commands/publish/preflight.rs @@ -50,6 +50,7 @@ pub(super) async fn run_security_audit_step( | Some(Platform::Android) | Some(Platform::Macos) | Some(Platform::Tvos) + | Some(Platform::Watchos) | Some(Platform::Web) | Some(Platform::Windows) => "gui", _ => "server", diff --git a/crates/perry/src/commands/publish/server_api.rs b/crates/perry/src/commands/publish/server_api.rs index 876c7246cd..a6269fd061 100644 --- a/crates/perry/src/commands/publish/server_api.rs +++ b/crates/perry/src/commands/publish/server_api.rs @@ -125,6 +125,16 @@ pub(super) struct BuildManifest { #[serde(skip_serializing_if = "Option::is_none")] pub(super) tvos_info_plist: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub(super) tvos_distribute: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) watchos_deployment_target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) watchos_encryption_exempt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) watchos_info_plist: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) watchos_distribute: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub(super) android_min_sdk: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(super) android_target_sdk: Option, From 46ab4080f0a824f8b652c9dc27ce72ae9e525e3f Mon Sep 17 00:00:00 2001 From: proggeramlug Date: Sat, 13 Jun 2026 13:33:13 +0200 Subject: [PATCH 2/2] fix(publish): satisfy file-size gate + update validate_credentials test call sites - Extract per-target entry/bundle_id resolution from mod.rs into resolve.rs (mod.rs was 2039 lines, over the 2000-line lint gate -> now 1954). - validate_credentials_for_distribute gained 4 params (is_tvos/tvos_distribute, is_watchos/watchos_distribute); update the 10 #[cfg(test)] call sites that cargo check skips but cargo test compiles, and add tvOS/watchOS credential validation tests. --- crates/perry/src/commands/publish/mod.rs | 127 +++-------------- crates/perry/src/commands/publish/resolve.rs | 138 +++++++++++++++++++ crates/perry/src/commands/publish/tests.rs | 121 +++++++++++++++- 3 files changed, 279 insertions(+), 107 deletions(-) create mode 100644 crates/perry/src/commands/publish/resolve.rs diff --git a/crates/perry/src/commands/publish/mod.rs b/crates/perry/src/commands/publish/mod.rs index 13fb0f4509..603e220900 100644 --- a/crates/perry/src/commands/publish/mod.rs +++ b/crates/perry/src/commands/publish/mod.rs @@ -22,6 +22,7 @@ mod args; mod config_types; mod credentials; mod preflight; +mod resolve; mod saved_config; mod server_api; mod tarball; @@ -45,6 +46,7 @@ use credentials::{ validate_credentials_for_distribute, }; use preflight::{ios_preflight_validation, macos_preflight_validation, run_security_audit_step}; +use resolve::{resolve_bundle_id, resolve_entry}; use server_api::{ BuildManifest, BuildResponse, CredentialsPayload, RegisterResponse, ServerMessage, }; @@ -210,54 +212,14 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> .unwrap_or_else(|| "https://hub.perryts.com".into()); // --- Resolve entry point --- - let entry = if is_android { - config - .android - .as_ref() - .and_then(|a| a.entry.clone()) - .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) - .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) - .unwrap_or_else(|| "src/main.ts".into()) - } else if is_ios { - config - .ios - .as_ref() - .and_then(|i| i.entry.clone()) - .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) - .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) - .unwrap_or_else(|| "src/main_ios.ts".into()) - } else if is_visionos { - config - .visionos - .as_ref() - .and_then(|i| i.entry.clone()) - .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) - .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) - .unwrap_or_else(|| "src/main_visionos.ts".into()) - } else if is_tvos { - config - .tvos - .as_ref() - .and_then(|t| t.entry.clone()) - .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) - .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) - .unwrap_or_else(|| "src/main_tvos.ts".into()) - } else if is_watchos { - config - .watchos - .as_ref() - .and_then(|w| w.entry.clone()) - .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) - .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) - .unwrap_or_else(|| "src/main_watchos.ts".into()) - } else { - config - .app - .as_ref() - .and_then(|a| a.entry.clone()) - .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) - .unwrap_or_else(|| "src/main.ts".into()) - }; + let entry = resolve_entry( + &config, + is_ios, + is_visionos, + is_tvos, + is_watchos, + is_android, + ); // --- Resolve version (allow override) --- let version = if interactive { @@ -329,64 +291,17 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) -> let app_bundle_id = config.app.as_ref().and_then(|a| a.bundle_id.clone()); let project_bundle_id = config.project.as_ref().and_then(|p| p.bundle_id.clone()); - let bundle_id = if is_android { - config - .android - .as_ref() - .and_then(|a| a.package_name.clone()) - .or_else(|| config.ios.as_ref().and_then(|i| i.bundle_id.clone())) - .or_else(|| config.macos.as_ref().and_then(|m| m.bundle_id.clone())) - .or_else(|| app_bundle_id.clone()) - .or_else(|| project_bundle_id.clone()) - .unwrap_or_else(|| format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-"))) - } else if is_ios { - config - .ios - .as_ref() - .and_then(|i| i.bundle_id.clone()) - .or_else(|| app_bundle_id.clone()) - .or_else(|| project_bundle_id.clone()) - .or_else(|| config.macos.as_ref().and_then(|m| m.bundle_id.clone())) - .unwrap_or_else(|| format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-"))) - } else if is_visionos { - config - .visionos - .as_ref() - .and_then(|i| i.bundle_id.clone()) - .or_else(|| app_bundle_id.clone()) - .or_else(|| project_bundle_id.clone()) - .or_else(|| config.ios.as_ref().and_then(|i| i.bundle_id.clone())) - .or_else(|| config.macos.as_ref().and_then(|m| m.bundle_id.clone())) - .unwrap_or_else(|| format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-"))) - } else if is_tvos { - config - .tvos - .as_ref() - .and_then(|t| t.bundle_id.clone()) - .or_else(|| app_bundle_id.clone()) - .or_else(|| project_bundle_id.clone()) - .or_else(|| config.ios.as_ref().and_then(|i| i.bundle_id.clone())) - .unwrap_or_else(|| format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-"))) - } else if is_watchos { - // A standalone watchOS app must have its OWN unique bundle id — do NOT - // fall back to the iOS app's id (App Store Connect rejects duplicates). - // The appstore/testflight preflight below hard-requires [watchos] bundle_id. - config - .watchos - .as_ref() - .and_then(|w| w.bundle_id.clone()) - .or_else(|| app_bundle_id.clone()) - .or_else(|| project_bundle_id.clone()) - .unwrap_or_else(|| format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-"))) - } else { - config - .macos - .as_ref() - .and_then(|m| m.bundle_id.clone()) - .or_else(|| app_bundle_id.clone()) - .or_else(|| project_bundle_id.clone()) - .unwrap_or_else(|| format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-"))) - }; + let bundle_id = resolve_bundle_id( + &config, + &app_name, + &app_bundle_id, + &project_bundle_id, + is_ios, + is_visionos, + is_tvos, + is_watchos, + is_android, + ); let mut icon = config .project diff --git a/crates/perry/src/commands/publish/resolve.rs b/crates/perry/src/commands/publish/resolve.rs new file mode 100644 index 0000000000..1b7c6f14c1 --- /dev/null +++ b/crates/perry/src/commands/publish/resolve.rs @@ -0,0 +1,138 @@ +//! Per-target resolution of the entry-point file and bundle id from +//! `perry.toml`. Extracted from `mod.rs` to keep it under the file-size gate. + +use super::config_types::PerryToml; + +/// Resolve the entry-point source file for the target platform, falling back +/// to `[app]`/`[project]` `entry` and finally a per-platform default. +pub(super) fn resolve_entry( + config: &PerryToml, + is_ios: bool, + is_visionos: bool, + is_tvos: bool, + is_watchos: bool, + is_android: bool, +) -> String { + if is_android { + config + .android + .as_ref() + .and_then(|a| a.entry.clone()) + .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) + .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) + .unwrap_or_else(|| "src/main.ts".into()) + } else if is_ios { + config + .ios + .as_ref() + .and_then(|i| i.entry.clone()) + .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) + .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) + .unwrap_or_else(|| "src/main_ios.ts".into()) + } else if is_visionos { + config + .visionos + .as_ref() + .and_then(|i| i.entry.clone()) + .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) + .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) + .unwrap_or_else(|| "src/main_visionos.ts".into()) + } else if is_tvos { + config + .tvos + .as_ref() + .and_then(|t| t.entry.clone()) + .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) + .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) + .unwrap_or_else(|| "src/main_tvos.ts".into()) + } else if is_watchos { + config + .watchos + .as_ref() + .and_then(|w| w.entry.clone()) + .or_else(|| config.app.as_ref().and_then(|a| a.entry.clone())) + .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) + .unwrap_or_else(|| "src/main_watchos.ts".into()) + } else { + config + .app + .as_ref() + .and_then(|a| a.entry.clone()) + .or_else(|| config.project.as_ref().and_then(|p| p.entry.clone())) + .unwrap_or_else(|| "src/main.ts".into()) + } +} + +/// Resolve the bundle id for the target platform. The `else` arm covers +/// macOS/Linux/Windows/Web. +pub(super) fn resolve_bundle_id( + config: &PerryToml, + app_name: &str, + app_bundle_id: &Option, + project_bundle_id: &Option, + is_ios: bool, + is_visionos: bool, + is_tvos: bool, + is_watchos: bool, + is_android: bool, +) -> String { + let default = || format!("com.perry.{}", app_name.to_lowercase().replace(' ', "-")); + if is_android { + config + .android + .as_ref() + .and_then(|a| a.package_name.clone()) + .or_else(|| config.ios.as_ref().and_then(|i| i.bundle_id.clone())) + .or_else(|| config.macos.as_ref().and_then(|m| m.bundle_id.clone())) + .or_else(|| app_bundle_id.clone()) + .or_else(|| project_bundle_id.clone()) + .unwrap_or_else(default) + } else if is_ios { + config + .ios + .as_ref() + .and_then(|i| i.bundle_id.clone()) + .or_else(|| app_bundle_id.clone()) + .or_else(|| project_bundle_id.clone()) + .or_else(|| config.macos.as_ref().and_then(|m| m.bundle_id.clone())) + .unwrap_or_else(default) + } else if is_visionos { + config + .visionos + .as_ref() + .and_then(|i| i.bundle_id.clone()) + .or_else(|| app_bundle_id.clone()) + .or_else(|| project_bundle_id.clone()) + .or_else(|| config.ios.as_ref().and_then(|i| i.bundle_id.clone())) + .or_else(|| config.macos.as_ref().and_then(|m| m.bundle_id.clone())) + .unwrap_or_else(default) + } else if is_tvos { + config + .tvos + .as_ref() + .and_then(|t| t.bundle_id.clone()) + .or_else(|| app_bundle_id.clone()) + .or_else(|| project_bundle_id.clone()) + .or_else(|| config.ios.as_ref().and_then(|i| i.bundle_id.clone())) + .unwrap_or_else(default) + } else if is_watchos { + // A standalone watchOS app must have its OWN unique bundle id — do NOT + // fall back to the iOS app's id (App Store Connect rejects duplicates). + // The appstore/testflight preflight in mod.rs hard-requires [watchos] bundle_id. + config + .watchos + .as_ref() + .and_then(|w| w.bundle_id.clone()) + .or_else(|| app_bundle_id.clone()) + .or_else(|| project_bundle_id.clone()) + .unwrap_or_else(default) + } else { + config + .macos + .as_ref() + .and_then(|m| m.bundle_id.clone()) + .or_else(|| app_bundle_id.clone()) + .or_else(|| project_bundle_id.clone()) + .unwrap_or_else(default) + } +} diff --git a/crates/perry/src/commands/publish/tests.rs b/crates/perry/src/commands/publish/tests.rs index e2a0b61f10..a07afcc75d 100644 --- a/crates/perry/src/commands/publish/tests.rs +++ b/crates/perry/src/commands/publish/tests.rs @@ -257,6 +257,10 @@ fn test_config_file_write_and_read() { let _ = fs::remove_dir(&dir); } +// The trailing `false, None, false, None` on each call is +// (is_tvos, tvos_distribute, is_watchos, watchos_distribute) — not applicable +// to the android/ios/macos cases below. tvOS/watchOS have dedicated tests. + #[test] fn test_validate_android_playstore_requires_json() { let result = validate_credentials_for_distribute( @@ -270,6 +274,10 @@ fn test_validate_android_playstore_requires_json() { None, // ios not applicable false, None, // macos not applicable + false, + None, // tvos not applicable + false, + None, // watchos not applicable ); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); @@ -290,6 +298,10 @@ fn test_validate_android_playstore_invalid_track() { None, false, None, + false, + None, + false, + None, ); assert!(result.is_err()); assert!(result @@ -313,6 +325,10 @@ fn test_validate_android_playstore_valid_tracks() { None, false, None, + false, + None, + false, + None, ); assert!(result.is_ok(), "track={track} should be valid"); } @@ -331,6 +347,10 @@ fn test_validate_ios_appstore_requires_creds() { None, // ios, missing creds false, None, + false, + None, + false, + None, ); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); @@ -351,6 +371,10 @@ fn test_validate_ios_testflight_requires_creds() { Some("key_content"), false, None, + false, + None, + false, + None, ); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Issuer ID")); @@ -360,7 +384,7 @@ fn test_validate_ios_testflight_requires_creds() { fn test_validate_ios_no_distribute_passes() { let result = validate_credentials_for_distribute( false, None, None, true, None, None, None, None, // ios but no distribute set - false, None, + false, None, false, None, false, None, ); assert!(result.is_ok()); } @@ -378,6 +402,10 @@ fn test_validate_macos_appstore_requires_creds() { None, true, Some("appstore"), // macos appstore, no creds + false, + None, + false, + None, ); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); @@ -398,6 +426,10 @@ fn test_validate_macos_testflight_requires_creds() { None, true, Some("testflight"), // macos testflight, no creds + false, + None, + false, + None, ); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); @@ -417,6 +449,10 @@ fn test_validate_macos_notarize_requires_creds() { None, true, Some("notarize"), // macos notarize, no creds + false, + None, + false, + None, ); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); @@ -436,6 +472,89 @@ fn test_validate_passes_when_all_present() { Some("p8"), false, None, + false, + None, + false, + None, + ); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_tvos_appstore_requires_creds() { + let result = validate_credentials_for_distribute( + false, + None, + None, + false, + None, + None, + None, + None, + false, + None, + true, + Some("appstore"), // tvos appstore, no creds + false, + None, + ); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("App Store Connect API credentials"), "{msg}"); + assert!(msg.contains("perry setup tvos"), "{msg}"); +} + +#[test] +fn test_validate_watchos_appstore_requires_creds() { + let result = validate_credentials_for_distribute( + false, + None, + None, + false, + None, + None, + None, + None, + false, + None, + false, + None, + true, + Some("appstore"), // watchos appstore, no creds + ); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("App Store Connect API credentials"), "{msg}"); + assert!(msg.contains("perry setup watchos"), "{msg}"); +} + +#[test] +fn test_validate_watchos_testflight_missing_issuer() { + let result = validate_credentials_for_distribute( + false, + None, + None, + false, + None, + Some("kid"), + None, + Some("p8"), + false, + None, + false, + None, + true, + Some("testflight"), // watchos testflight, issuer missing + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Issuer ID")); +} + +#[test] +fn test_validate_watchos_no_distribute_passes() { + let result = validate_credentials_for_distribute( + false, None, None, false, None, None, None, None, false, None, false, None, true, + None, // watchos but no distribute set ); assert!(result.is_ok()); }