Dadoum commited on 2026-07-10 00:39:34
Showing 5 changed files, with 566 additions and 61 deletions.
| ... | ... |
@@ -1,7 +1,4 @@ |
| 1 |
-use crate::app::entitlements::{
|
|
| 2 |
- effective_bundle_identifier, effective_bundle_identifier_for_app, |
|
| 3 |
- effective_nested_bundle_identifier, |
|
| 4 |
-}; |
|
| 1 |
+use crate::app::entitlements::{effective_bundle_identifier, effective_nested_bundle_identifier};
|
|
| 5 | 2 |
use crate::app::models::{
|
| 6 | 3 |
AccountOption, AdiBackendKind, AdiBackendOption, AppIdOption, AppOption, DeveloperDeviceOption, |
| 7 | 4 |
DeviceOption, MachineIdentity, PatchOption, TeamOption, |
| ... | ... |
@@ -71,6 +68,20 @@ impl AppIdProvisioningPlan {
|
| 71 | 68 |
} |
| 72 | 69 |
} |
| 73 | 70 |
|
| 71 |
+#[derive(Clone, Debug)] |
|
| 72 |
+struct SigningAppIdTarget {
|
|
| 73 |
+ registration_identifier: String, |
|
| 74 |
+ bundle_identifier: String, |
|
| 75 |
+ name: String, |
|
| 76 |
+ existing: Option<AppIdOption>, |
|
| 77 |
+} |
|
| 78 |
+ |
|
| 79 |
+#[derive(Clone, Debug)] |
|
| 80 |
+struct SigningAppIdTargets {
|
|
| 81 |
+ root: SigningAppIdTarget, |
|
| 82 |
+ nested: Vec<SigningAppIdTarget>, |
|
| 83 |
+} |
|
| 84 |
+ |
|
| 74 | 85 |
#[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 75 | 86 |
pub(crate) enum SignIpaProgress {
|
| 76 | 87 |
Preparing, |
| ... | ... |
@@ -409,14 +420,20 @@ pub(crate) async fn sign_ipa( |
| 409 | 420 |
.map_err(AppError::from)?; |
| 410 | 421 |
|
| 411 | 422 |
progress(SignIpaProgress::ResolvingAppId); |
| 412 |
- let root_identifier = developer_app_identifier(&request.team_id, request.app.bundle_id()); |
|
| 423 |
+ let targets = signing_app_id_targets( |
|
| 424 |
+ &request.team_id, |
|
| 425 |
+ &request.team_app_ids, |
|
| 426 |
+ request.auto_app_id, |
|
| 427 |
+ request.selected_app_id.as_ref(), |
|
| 428 |
+ &request.app, |
|
| 429 |
+ )?; |
|
| 413 | 430 |
let (app_id, mut updated_account) = resolve_signing_app_id( |
| 414 | 431 |
request.developer_context.clone(), |
| 415 | 432 |
&request.team_id, |
| 416 | 433 |
&request.team_app_ids, |
| 417 | 434 |
request.auto_app_id, |
| 418 |
- request.selected_app_id, |
|
| 419 |
- &root_identifier, |
|
| 435 |
+ request.selected_app_id.clone(), |
|
| 436 |
+ &targets.root.registration_identifier, |
|
| 420 | 437 |
request.app.name(), |
| 421 | 438 |
) |
| 422 | 439 |
.await?; |
| ... | ... |
@@ -425,8 +442,7 @@ pub(crate) async fn sign_ipa( |
| 425 | 442 |
"The selected App ID is missing its Xcode identifier. Refresh Developer Settings, then try again.", |
| 426 | 443 |
)); |
| 427 | 444 |
} |
| 428 |
- let expected_identifier = developer_app_identifier(&request.team_id, request.app.bundle_id()); |
|
| 429 |
- if !app_id_matches_identifier(&app_id.identifier, &expected_identifier) {
|
|
| 445 |
+ if !app_id_matches_identifier(&app_id.identifier, &targets.root.bundle_identifier) {
|
|
| 430 | 446 |
return Err(AppError::from(format!( |
| 431 | 447 |
"App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
|
| 432 | 448 |
app_id.identifier, |
| ... | ... |
@@ -438,32 +454,25 @@ pub(crate) async fn sign_ipa( |
| 438 | 454 |
.as_ref() |
| 439 | 455 |
.and_then(|account| team_app_ids_for_account(account, &request.team_id)) |
| 440 | 456 |
.unwrap_or_else(|| request.team_app_ids.clone()); |
| 441 |
- let effective_bundle_id = effective_bundle_identifier_for_app(&request.app, &request.team_id); |
|
| 457 |
+ let effective_bundle_id = targets.root.bundle_identifier; |
|
| 442 | 458 |
let mut bundle_id_replacements = BTreeMap::from([( |
| 443 | 459 |
request.app.bundle_id().to_string(), |
| 444 | 460 |
effective_bundle_id.clone(), |
| 445 | 461 |
)]); |
| 446 | 462 |
let mut profile_requests = vec![(effective_bundle_id.clone(), app_id.developer_id)]; |
| 447 | 463 |
|
| 448 |
- for nested_bundle in &request.app.nested_bundles {
|
|
| 449 |
- let effective_nested_bundle_id = effective_nested_bundle_identifier( |
|
| 450 |
- request.app.original_bundle_id(), |
|
| 451 |
- request.app.bundle_id(), |
|
| 452 |
- &nested_bundle.bundle_id, |
|
| 453 |
- &request.team_id, |
|
| 454 |
- ); |
|
| 464 |
+ for (nested_bundle, target) in request.app.nested_bundles.iter().zip(targets.nested) {
|
|
| 455 | 465 |
let (nested_app_id, account_update) = resolve_signing_app_id( |
| 456 | 466 |
request.developer_context.clone(), |
| 457 | 467 |
&request.team_id, |
| 458 | 468 |
&team_app_ids, |
| 459 | 469 |
true, |
| 460 | 470 |
None, |
| 461 |
- &effective_nested_bundle_id, |
|
| 471 |
+ &target.registration_identifier, |
|
| 462 | 472 |
&nested_bundle.name, |
| 463 | 473 |
) |
| 464 | 474 |
.await?; |
| 465 |
- let expected_identifier = effective_nested_bundle_id.clone(); |
|
| 466 |
- if !app_id_matches_identifier(&nested_app_id.identifier, &expected_identifier) {
|
|
| 475 |
+ if !app_id_matches_identifier(&nested_app_id.identifier, &target.bundle_identifier) {
|
|
| 467 | 476 |
return Err(AppError::from(format!( |
| 468 | 477 |
"App ID {} does not match nested bundle identifier {}.",
|
| 469 | 478 |
nested_app_id.identifier, nested_bundle.bundle_id |
| ... | ... |
@@ -482,9 +491,9 @@ pub(crate) async fn sign_ipa( |
| 482 | 491 |
} |
| 483 | 492 |
bundle_id_replacements.insert( |
| 484 | 493 |
nested_bundle.bundle_id.clone(), |
| 485 |
- effective_nested_bundle_id.clone(), |
|
| 494 |
+ target.bundle_identifier.clone(), |
|
| 486 | 495 |
); |
| 487 |
- profile_requests.push((effective_nested_bundle_id, nested_app_id.developer_id)); |
|
| 496 |
+ profile_requests.push((target.bundle_identifier, nested_app_id.developer_id)); |
|
| 488 | 497 |
} |
| 489 | 498 |
|
| 490 | 499 |
progress(SignIpaProgress::DownloadingProfile); |
| ... | ... |
@@ -538,41 +547,24 @@ pub(crate) fn app_id_provisioning_plan( |
| 538 | 547 |
selected_app_id: Option<AppIdOption>, |
| 539 | 548 |
app: &AppOption, |
| 540 | 549 |
) -> AppResult<AppIdProvisioningPlan> {
|
| 550 |
+ let targets = signing_app_id_targets( |
|
| 551 |
+ &team.identifier, |
|
| 552 |
+ &team.app_ids, |
|
| 553 |
+ auto_app_id, |
|
| 554 |
+ selected_app_id.as_ref(), |
|
| 555 |
+ app, |
|
| 556 |
+ )?; |
|
| 541 | 557 |
let mut missing = BTreeMap::<String, String>::new(); |
| 542 |
- let root_identifier = developer_app_identifier(&team.identifier, app.bundle_id()); |
|
| 543 | 558 |
|
| 544 |
- if auto_app_id {
|
|
| 545 |
- add_missing_app_id( |
|
| 546 |
- &mut missing, |
|
| 547 |
- &team.app_ids, |
|
| 548 |
- root_identifier, |
|
| 549 |
- app.name().to_string(), |
|
| 550 |
- ); |
|
| 551 |
- } else {
|
|
| 552 |
- let selected_app_id = selected_app_id.ok_or_else(|| {
|
|
| 553 |
- AppError::from("Select an App ID in Developer Settings before signing.")
|
|
| 554 |
- })?; |
|
| 555 |
- if !app_id_matches_identifier(&selected_app_id.identifier, &root_identifier) {
|
|
| 556 |
- return Err(AppError::from(format!( |
|
| 557 |
- "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
|
|
| 558 |
- selected_app_id.identifier, |
|
| 559 |
- app.bundle_id() |
|
| 560 |
- ))); |
|
| 559 |
+ if targets.root.existing.is_none() {
|
|
| 560 |
+ missing.insert(targets.root.registration_identifier, targets.root.name); |
|
| 561 | 561 |
} |
| 562 |
+ for target in targets.nested {
|
|
| 563 |
+ if target.existing.is_none() {
|
|
| 564 |
+ missing |
|
| 565 |
+ .entry(target.registration_identifier) |
|
| 566 |
+ .or_insert(target.name); |
|
| 562 | 567 |
} |
| 563 |
- |
|
| 564 |
- for nested_bundle in &app.nested_bundles {
|
|
| 565 |
- add_missing_app_id( |
|
| 566 |
- &mut missing, |
|
| 567 |
- &team.app_ids, |
|
| 568 |
- effective_nested_bundle_identifier( |
|
| 569 |
- app.original_bundle_id(), |
|
| 570 |
- app.bundle_id(), |
|
| 571 |
- &nested_bundle.bundle_id, |
|
| 572 |
- &team.identifier, |
|
| 573 |
- ), |
|
| 574 |
- nested_bundle.name.clone(), |
|
| 575 |
- ); |
|
| 576 | 568 |
} |
| 577 | 569 |
|
| 578 | 570 |
let app_ids = missing |
| ... | ... |
@@ -595,18 +587,196 @@ pub(crate) fn app_id_provisioning_plan( |
| 595 | 587 |
}) |
| 596 | 588 |
} |
| 597 | 589 |
|
| 598 |
-fn add_missing_app_id( |
|
| 599 |
- missing: &mut BTreeMap<String, String>, |
|
| 600 |
- existing: &[AppIdOption], |
|
| 601 |
- identifier: String, |
|
| 602 |
- name: String, |
|
| 590 |
+fn signing_app_id_targets( |
|
| 591 |
+ team_id: &str, |
|
| 592 |
+ team_app_ids: &[AppIdOption], |
|
| 593 |
+ auto_app_id: bool, |
|
| 594 |
+ selected_app_id: Option<&AppIdOption>, |
|
| 595 |
+ app: &AppOption, |
|
| 596 |
+) -> AppResult<SigningAppIdTargets> {
|
|
| 597 |
+ let fallback_identifier = developer_app_identifier(team_id, app.bundle_id()); |
|
| 598 |
+ if !auto_app_id {
|
|
| 599 |
+ let selected = selected_app_id.ok_or_else(|| {
|
|
| 600 |
+ AppError::from("Select an App ID in Developer Settings before signing.")
|
|
| 601 |
+ })?; |
|
| 602 |
+ if !app_id_matches_bundle( |
|
| 603 |
+ &selected.identifier, |
|
| 604 |
+ app.bundle_id(), |
|
| 605 |
+ &fallback_identifier, |
|
| 606 |
+ team_id, |
|
| 603 | 607 |
) {
|
| 604 |
- if !existing |
|
| 608 |
+ return Err(AppError::from(format!( |
|
| 609 |
+ "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
|
|
| 610 |
+ selected.identifier, |
|
| 611 |
+ app.bundle_id() |
|
| 612 |
+ ))); |
|
| 613 |
+ } |
|
| 614 |
+ let bundle_identifier = if selected.identifier.ends_with('*') {
|
|
| 615 |
+ fallback_identifier |
|
| 616 |
+ } else {
|
|
| 617 |
+ selected.identifier.clone() |
|
| 618 |
+ }; |
|
| 619 |
+ let root = SigningAppIdTarget {
|
|
| 620 |
+ registration_identifier: selected.identifier.clone(), |
|
| 621 |
+ bundle_identifier: bundle_identifier.clone(), |
|
| 622 |
+ name: app.name().to_string(), |
|
| 623 |
+ existing: Some(selected.clone()), |
|
| 624 |
+ }; |
|
| 625 |
+ return Ok(SigningAppIdTargets {
|
|
| 626 |
+ nested: nested_app_id_targets(team_id, team_app_ids, app, &bundle_identifier), |
|
| 627 |
+ root, |
|
| 628 |
+ }); |
|
| 629 |
+ } |
|
| 630 |
+ |
|
| 631 |
+ let canonical_bundle_id = identifier_without_team_component(app.bundle_id(), team_id); |
|
| 632 |
+ let mut candidates = team_app_ids |
|
| 605 | 633 |
.iter() |
| 606 |
- .any(|app_id| app_id.identifier == identifier) |
|
| 634 |
+ .filter(|app_id| {
|
|
| 635 |
+ !app_id.identifier.contains('*')
|
|
| 636 |
+ && identifier_without_team_component(&app_id.identifier, team_id) |
|
| 637 |
+ == canonical_bundle_id |
|
| 638 |
+ }) |
|
| 639 |
+ .map(|app_id| SigningAppIdTarget {
|
|
| 640 |
+ registration_identifier: app_id.identifier.clone(), |
|
| 641 |
+ bundle_identifier: app_id.identifier.clone(), |
|
| 642 |
+ name: app.name().to_string(), |
|
| 643 |
+ existing: Some(app_id.clone()), |
|
| 644 |
+ }) |
|
| 645 |
+ .collect::<Vec<_>>(); |
|
| 646 |
+ if !candidates |
|
| 647 |
+ .iter() |
|
| 648 |
+ .any(|target| target.registration_identifier == fallback_identifier) |
|
| 607 | 649 |
{
|
| 608 |
- missing.entry(identifier).or_insert(name); |
|
| 650 |
+ candidates.push(SigningAppIdTarget {
|
|
| 651 |
+ registration_identifier: fallback_identifier.clone(), |
|
| 652 |
+ bundle_identifier: fallback_identifier.clone(), |
|
| 653 |
+ name: app.name().to_string(), |
|
| 654 |
+ existing: team_app_ids |
|
| 655 |
+ .iter() |
|
| 656 |
+ .find(|app_id| app_id.identifier == fallback_identifier) |
|
| 657 |
+ .cloned(), |
|
| 658 |
+ }); |
|
| 609 | 659 |
} |
| 660 |
+ |
|
| 661 |
+ candidates |
|
| 662 |
+ .into_iter() |
|
| 663 |
+ .map(|root| {
|
|
| 664 |
+ let nested = nested_app_id_targets(team_id, team_app_ids, app, &root.bundle_identifier); |
|
| 665 |
+ let missing = usize::from(root.existing.is_none()) |
|
| 666 |
+ + nested |
|
| 667 |
+ .iter() |
|
| 668 |
+ .filter(|target| target.existing.is_none()) |
|
| 669 |
+ .count(); |
|
| 670 |
+ let preference = |
|
| 671 |
+ if root.existing.is_some() && root.bundle_identifier == app.bundle_id().trim() {
|
|
| 672 |
+ 0 |
|
| 673 |
+ } else if root.existing.is_some() && root.bundle_identifier == fallback_identifier {
|
|
| 674 |
+ 1 |
|
| 675 |
+ } else if root.existing.is_some() {
|
|
| 676 |
+ 2 |
|
| 677 |
+ } else {
|
|
| 678 |
+ 3 |
|
| 679 |
+ }; |
|
| 680 |
+ (missing, preference, SigningAppIdTargets { root, nested })
|
|
| 681 |
+ }) |
|
| 682 |
+ .min_by_key(|(missing, preference, _)| (*missing, *preference)) |
|
| 683 |
+ .map(|(_, _, targets)| targets) |
|
| 684 |
+ .ok_or_else(|| AppError::from("Could not determine the App IDs required for signing."))
|
|
| 685 |
+} |
|
| 686 |
+ |
|
| 687 |
+fn nested_app_id_targets( |
|
| 688 |
+ team_id: &str, |
|
| 689 |
+ team_app_ids: &[AppIdOption], |
|
| 690 |
+ app: &AppOption, |
|
| 691 |
+ root_bundle_identifier: &str, |
|
| 692 |
+) -> Vec<SigningAppIdTarget> {
|
|
| 693 |
+ app.nested_bundles |
|
| 694 |
+ .iter() |
|
| 695 |
+ .map(|nested_bundle| {
|
|
| 696 |
+ let desired_identifier = rebased_nested_bundle_identifier( |
|
| 697 |
+ app.original_bundle_id(), |
|
| 698 |
+ app.bundle_id(), |
|
| 699 |
+ root_bundle_identifier, |
|
| 700 |
+ &nested_bundle.bundle_id, |
|
| 701 |
+ team_id, |
|
| 702 |
+ ); |
|
| 703 |
+ let canonical_desired = identifier_without_team_component(&desired_identifier, team_id); |
|
| 704 |
+ let root_prefix = format!("{root_bundle_identifier}.");
|
|
| 705 |
+ let existing = team_app_ids |
|
| 706 |
+ .iter() |
|
| 707 |
+ .find(|app_id| app_id.identifier == desired_identifier) |
|
| 708 |
+ .or_else(|| {
|
|
| 709 |
+ team_app_ids.iter().find(|app_id| {
|
|
| 710 |
+ !app_id.identifier.contains('*')
|
|
| 711 |
+ && app_id.identifier.starts_with(&root_prefix) |
|
| 712 |
+ && identifier_without_team_component(&app_id.identifier, team_id) |
|
| 713 |
+ == canonical_desired |
|
| 714 |
+ }) |
|
| 715 |
+ }) |
|
| 716 |
+ .cloned(); |
|
| 717 |
+ let identifier = existing |
|
| 718 |
+ .as_ref() |
|
| 719 |
+ .map(|app_id| app_id.identifier.clone()) |
|
| 720 |
+ .unwrap_or(desired_identifier); |
|
| 721 |
+ SigningAppIdTarget {
|
|
| 722 |
+ registration_identifier: identifier.clone(), |
|
| 723 |
+ bundle_identifier: identifier, |
|
| 724 |
+ name: nested_bundle.name.clone(), |
|
| 725 |
+ existing, |
|
| 726 |
+ } |
|
| 727 |
+ }) |
|
| 728 |
+ .collect() |
|
| 729 |
+} |
|
| 730 |
+ |
|
| 731 |
+fn rebased_nested_bundle_identifier( |
|
| 732 |
+ original_root_bundle_id: &str, |
|
| 733 |
+ current_root_bundle_id: &str, |
|
| 734 |
+ effective_root_bundle_id: &str, |
|
| 735 |
+ nested_bundle_id: &str, |
|
| 736 |
+ team_id: &str, |
|
| 737 |
+) -> String {
|
|
| 738 |
+ let original_root = identifier_without_team_component(original_root_bundle_id, team_id); |
|
| 739 |
+ let current_root = identifier_without_team_component(current_root_bundle_id, team_id); |
|
| 740 |
+ let nested = identifier_without_team_component(nested_bundle_id, team_id); |
|
| 741 |
+ nested |
|
| 742 |
+ .strip_prefix(&original_root) |
|
| 743 |
+ .or_else(|| nested.strip_prefix(¤t_root)) |
|
| 744 |
+ .filter(|suffix| suffix.starts_with('.'))
|
|
| 745 |
+ .map(|suffix| format!("{effective_root_bundle_id}{suffix}"))
|
|
| 746 |
+ .unwrap_or_else(|| {
|
|
| 747 |
+ effective_nested_bundle_identifier( |
|
| 748 |
+ original_root_bundle_id, |
|
| 749 |
+ current_root_bundle_id, |
|
| 750 |
+ nested_bundle_id, |
|
| 751 |
+ team_id, |
|
| 752 |
+ ) |
|
| 753 |
+ }) |
|
| 754 |
+} |
|
| 755 |
+ |
|
| 756 |
+fn identifier_without_team_component(identifier: &str, team_id: &str) -> String {
|
|
| 757 |
+ let team_id = team_id.trim(); |
|
| 758 |
+ if team_id.is_empty() {
|
|
| 759 |
+ return identifier.trim().to_string(); |
|
| 760 |
+ } |
|
| 761 |
+ identifier |
|
| 762 |
+ .trim() |
|
| 763 |
+ .split('.')
|
|
| 764 |
+ .filter(|component| *component != team_id) |
|
| 765 |
+ .collect::<Vec<_>>() |
|
| 766 |
+ .join(".")
|
|
| 767 |
+} |
|
| 768 |
+ |
|
| 769 |
+fn app_id_matches_bundle( |
|
| 770 |
+ app_id: &str, |
|
| 771 |
+ bundle_id: &str, |
|
| 772 |
+ fallback_identifier: &str, |
|
| 773 |
+ team_id: &str, |
|
| 774 |
+) -> bool {
|
|
| 775 |
+ app_id_matches_identifier(app_id, bundle_id) |
|
| 776 |
+ || app_id_matches_identifier(app_id, fallback_identifier) |
|
| 777 |
+ || (!app_id.contains('*')
|
|
| 778 |
+ && identifier_without_team_component(app_id, team_id) |
|
| 779 |
+ == identifier_without_team_component(bundle_id, team_id)) |
|
| 610 | 780 |
} |
| 611 | 781 |
|
| 612 | 782 |
fn team_app_ids_for_account(account: &AccountOption, team_id: &str) -> Option<Vec<AppIdOption>> {
|
| ... | ... |
@@ -1016,6 +1186,91 @@ mod tests {
|
| 1016 | 1186 |
assert_eq!(plan.remaining_after_signing(), Some(3)); |
| 1017 | 1187 |
} |
| 1018 | 1188 |
|
| 1189 |
+ #[test] |
|
| 1190 |
+ fn provisioning_plan_reuses_unchanged_registered_app_ids() {
|
|
| 1191 |
+ let app = app("com.example.app", &["com.example.app.widget"]);
|
|
| 1192 |
+ let team = team( |
|
| 1193 |
+ vec![app_id("com.example.app"), app_id("com.example.app.widget")],
|
|
| 1194 |
+ Some(5), |
|
| 1195 |
+ ); |
|
| 1196 |
+ |
|
| 1197 |
+ let targets = |
|
| 1198 |
+ signing_app_id_targets(&team.identifier, &team.app_ids, true, None, &app).unwrap(); |
|
| 1199 |
+ let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap(); |
|
| 1200 |
+ |
|
| 1201 |
+ assert!(plan.app_ids.is_empty()); |
|
| 1202 |
+ assert_eq!(targets.root.bundle_identifier, "com.example.app"); |
|
| 1203 |
+ assert_eq!( |
|
| 1204 |
+ targets.nested[0].bundle_identifier, |
|
| 1205 |
+ "com.example.app.widget" |
|
| 1206 |
+ ); |
|
| 1207 |
+ } |
|
| 1208 |
+ |
|
| 1209 |
+ #[test] |
|
| 1210 |
+ fn provisioning_plan_reuses_team_prefixed_registered_app_ids() {
|
|
| 1211 |
+ let app = app("com.example.app", &["com.example.app.widget"]);
|
|
| 1212 |
+ let team = team( |
|
| 1213 |
+ vec![ |
|
| 1214 |
+ app_id("TEAM.com.example.app"),
|
|
| 1215 |
+ app_id("TEAM.com.example.app.widget"),
|
|
| 1216 |
+ ], |
|
| 1217 |
+ Some(5), |
|
| 1218 |
+ ); |
|
| 1219 |
+ |
|
| 1220 |
+ let targets = |
|
| 1221 |
+ signing_app_id_targets(&team.identifier, &team.app_ids, true, None, &app).unwrap(); |
|
| 1222 |
+ let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap(); |
|
| 1223 |
+ |
|
| 1224 |
+ assert!(plan.app_ids.is_empty()); |
|
| 1225 |
+ assert_eq!(targets.root.bundle_identifier, "TEAM.com.example.app"); |
|
| 1226 |
+ assert_eq!( |
|
| 1227 |
+ targets.nested[0].bundle_identifier, |
|
| 1228 |
+ "TEAM.com.example.app.widget" |
|
| 1229 |
+ ); |
|
| 1230 |
+ } |
|
| 1231 |
+ |
|
| 1232 |
+ #[test] |
|
| 1233 |
+ fn provisioning_plan_reuses_registered_ids_after_a_root_bundle_override() {
|
|
| 1234 |
+ let mut app = app( |
|
| 1235 |
+ "com.google.ios.youtube", |
|
| 1236 |
+ &["com.google.ios.youtube.OpenYouTube.Extension"], |
|
| 1237 |
+ ); |
|
| 1238 |
+ app.metadata |
|
| 1239 |
+ .bundle_id |
|
| 1240 |
+ .set_override("ddcom.google.ios.youtube".to_string());
|
|
| 1241 |
+ let team = team( |
|
| 1242 |
+ vec![ |
|
| 1243 |
+ app_id("ddcom.google.ios.youtube.TEAM"),
|
|
| 1244 |
+ app_id("ddcom.google.ios.youtube.TEAM.OpenYouTube.Extension"),
|
|
| 1245 |
+ ], |
|
| 1246 |
+ Some(5), |
|
| 1247 |
+ ); |
|
| 1248 |
+ |
|
| 1249 |
+ let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap(); |
|
| 1250 |
+ |
|
| 1251 |
+ assert!(plan.app_ids.is_empty()); |
|
| 1252 |
+ } |
|
| 1253 |
+ |
|
| 1254 |
+ #[test] |
|
| 1255 |
+ fn provisioning_plan_chooses_the_existing_layout_with_fewer_missing_ids() {
|
|
| 1256 |
+ let app = app("com.example.app", &["com.example.app.widget"]);
|
|
| 1257 |
+ let team = team( |
|
| 1258 |
+ vec![ |
|
| 1259 |
+ app_id("com.example.app.TEAM"),
|
|
| 1260 |
+ app_id("TEAM.com.example.app"),
|
|
| 1261 |
+ app_id("TEAM.com.example.app.widget"),
|
|
| 1262 |
+ ], |
|
| 1263 |
+ Some(5), |
|
| 1264 |
+ ); |
|
| 1265 |
+ |
|
| 1266 |
+ let targets = |
|
| 1267 |
+ signing_app_id_targets(&team.identifier, &team.app_ids, true, None, &app).unwrap(); |
|
| 1268 |
+ let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap(); |
|
| 1269 |
+ |
|
| 1270 |
+ assert!(plan.app_ids.is_empty()); |
|
| 1271 |
+ assert_eq!(targets.root.bundle_identifier, "TEAM.com.example.app"); |
|
| 1272 |
+ } |
|
| 1273 |
+ |
|
| 1019 | 1274 |
#[test] |
| 1020 | 1275 |
fn provisioning_plan_rebases_nested_ids_when_root_bundle_id_is_overridden() {
|
| 1021 | 1276 |
let mut app = app("com.example.app", &["com.example.app.SubApp.Extension"]);
|
| ... | ... |
@@ -21,6 +21,9 @@ pub(crate) fn effective_bundle_identifier_for_app(app: &AppOption, team_id: &str |
| 21 | 21 |
pub(crate) fn effective_bundle_identifier(bundle_id: &str, team_id: &str) -> String {
|
| 22 | 22 |
let team_id = team_id.trim(); |
| 23 | 23 |
if team_id.is_empty() |
| 24 |
+ || bundle_id |
|
| 25 |
+ .strip_prefix(team_id) |
|
| 26 |
+ .is_some_and(|suffix| suffix.starts_with('.'))
|
|
| 24 | 27 |
|| bundle_id |
| 25 | 28 |
.strip_suffix(team_id) |
| 26 | 29 |
.is_some_and(|prefix| prefix.ends_with('.'))
|
| ... | ... |
@@ -246,6 +249,10 @@ mod tests {
|
| 246 | 249 |
effective_bundle_identifier("com.example.app.TEAM123", "TEAM123"),
|
| 247 | 250 |
"com.example.app.TEAM123" |
| 248 | 251 |
); |
| 252 |
+ assert_eq!( |
|
| 253 |
+ effective_bundle_identifier("TEAM123.com.example.app", "TEAM123"),
|
|
| 254 |
+ "TEAM123.com.example.app" |
|
| 255 |
+ ); |
|
| 249 | 256 |
assert_eq!( |
| 250 | 257 |
effective_nested_bundle_identifier( |
| 251 | 258 |
"com.example.app", |
| ... | ... |
@@ -22,6 +22,8 @@ fn main() {
|
| 22 | 22 |
.with_quit_mode(QuitMode::LastWindowClosed) |
| 23 | 23 |
.run(|cx: &mut App| {
|
| 24 | 24 |
gpui_component::init(cx); |
| 25 |
+ #[cfg(target_os = "linux")] |
|
| 26 |
+ ui::prompt::install(cx); |
|
| 25 | 27 |
|
| 26 | 28 |
let window_size = size(px(WINDOW_WIDTH), px(WINDOW_HEIGHT)); |
| 27 | 29 |
let bounds = Bounds::centered(None, window_size, cx); |
| ... | ... |
@@ -0,0 +1,239 @@ |
| 1 |
+use crate::ui::theme::tokens; |
|
| 2 |
+use gpui::{
|
|
| 3 |
+ div, hsla, opaque_grey, point, px, App, AppContext as _, BoxShadow, Context, EventEmitter, |
|
| 4 |
+ FocusHandle, Focusable, FontWeight, InteractiveElement, IntoElement, KeyDownEvent, |
|
| 5 |
+ ParentElement, PromptButton, PromptLevel, PromptResponse, Render, StatefulInteractiveElement, |
|
| 6 |
+ Styled, Window, |
|
| 7 |
+}; |
|
| 8 |
+use gpui_component::button::{Button, ButtonVariants as _};
|
|
| 9 |
+ |
|
| 10 |
+const PROMPT_WIDTH: f32 = 560.; |
|
| 11 |
+const PROMPT_BODY_MAX_HEIGHT: f32 = 260.; |
|
| 12 |
+ |
|
| 13 |
+pub(crate) fn install(cx: &mut App) {
|
|
| 14 |
+ cx.set_prompt_builder(|level, message, detail, actions, handle, window, cx| {
|
|
| 15 |
+ let prompt = cx.new(|cx| AppPrompt {
|
|
| 16 |
+ level, |
|
| 17 |
+ message: message.to_string(), |
|
| 18 |
+ detail: detail.map(ToString::to_string), |
|
| 19 |
+ actions: actions.to_vec(), |
|
| 20 |
+ focus_handle: cx.focus_handle(), |
|
| 21 |
+ }); |
|
| 22 |
+ |
|
| 23 |
+ handle.with_view(prompt, window, cx) |
|
| 24 |
+ }); |
|
| 25 |
+} |
|
| 26 |
+ |
|
| 27 |
+struct AppPrompt {
|
|
| 28 |
+ level: PromptLevel, |
|
| 29 |
+ message: String, |
|
| 30 |
+ detail: Option<String>, |
|
| 31 |
+ actions: Vec<PromptButton>, |
|
| 32 |
+ focus_handle: FocusHandle, |
|
| 33 |
+} |
|
| 34 |
+ |
|
| 35 |
+impl AppPrompt {
|
|
| 36 |
+ fn respond(&mut self, index: usize, cx: &mut Context<Self>) {
|
|
| 37 |
+ cx.emit(PromptResponse(index)); |
|
| 38 |
+ cx.stop_propagation(); |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ fn handle_key_down( |
|
| 42 |
+ &mut self, |
|
| 43 |
+ event: &KeyDownEvent, |
|
| 44 |
+ window: &mut Window, |
|
| 45 |
+ cx: &mut Context<Self>, |
|
| 46 |
+ ) {
|
|
| 47 |
+ let response = match event.keystroke.key.as_str() {
|
|
| 48 |
+ "enter" => self.actions.iter().position(|action| !action.is_cancel()), |
|
| 49 |
+ "escape" => self.actions.iter().position(PromptButton::is_cancel), |
|
| 50 |
+ _ => None, |
|
| 51 |
+ }; |
|
| 52 |
+ |
|
| 53 |
+ if let Some(index) = response {
|
|
| 54 |
+ window.prevent_default(); |
|
| 55 |
+ self.respond(index, cx); |
|
| 56 |
+ } |
|
| 57 |
+ } |
|
| 58 |
+} |
|
| 59 |
+ |
|
| 60 |
+impl Render for AppPrompt {
|
|
| 61 |
+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
| 62 |
+ let colors = tokens(); |
|
| 63 |
+ let (level_background, level_foreground, level_mark) = match self.level {
|
|
| 64 |
+ PromptLevel::Info => (colors.accent_surface, colors.accent, "i"), |
|
| 65 |
+ PromptLevel::Warning => (colors.warning_surface, colors.warning, "!"), |
|
| 66 |
+ PromptLevel::Critical => (colors.danger_surface, colors.danger, "!"), |
|
| 67 |
+ }; |
|
| 68 |
+ |
|
| 69 |
+ let header = div() |
|
| 70 |
+ .w_full() |
|
| 71 |
+ .flex() |
|
| 72 |
+ .items_start() |
|
| 73 |
+ .gap_3() |
|
| 74 |
+ .child( |
|
| 75 |
+ div() |
|
| 76 |
+ .flex_none() |
|
| 77 |
+ .size_10() |
|
| 78 |
+ .rounded_full() |
|
| 79 |
+ .bg(level_background) |
|
| 80 |
+ .text_color(level_foreground) |
|
| 81 |
+ .flex() |
|
| 82 |
+ .items_center() |
|
| 83 |
+ .justify_center() |
|
| 84 |
+ .text_lg() |
|
| 85 |
+ .font_weight(FontWeight::BOLD) |
|
| 86 |
+ .child(level_mark), |
|
| 87 |
+ ) |
|
| 88 |
+ .child( |
|
| 89 |
+ div() |
|
| 90 |
+ .min_w_0() |
|
| 91 |
+ .flex_1() |
|
| 92 |
+ .pt_1() |
|
| 93 |
+ .text_lg() |
|
| 94 |
+ .font_weight(FontWeight::SEMIBOLD) |
|
| 95 |
+ .text_color(colors.text_primary) |
|
| 96 |
+ .whitespace_normal() |
|
| 97 |
+ .child(self.message.clone()), |
|
| 98 |
+ ); |
|
| 99 |
+ |
|
| 100 |
+ let body = self.detail.clone().map(|detail| {
|
|
| 101 |
+ div() |
|
| 102 |
+ .id("app-prompt-detail")
|
|
| 103 |
+ .w_full() |
|
| 104 |
+ .max_h(px(PROMPT_BODY_MAX_HEIGHT)) |
|
| 105 |
+ .overflow_hidden() |
|
| 106 |
+ .overflow_y_scroll() |
|
| 107 |
+ .scrollbar_width(px(8.)) |
|
| 108 |
+ .rounded_md() |
|
| 109 |
+ .border_1() |
|
| 110 |
+ .border_color(colors.border) |
|
| 111 |
+ .bg(colors.surface_subtle) |
|
| 112 |
+ .child( |
|
| 113 |
+ div() |
|
| 114 |
+ .w_full() |
|
| 115 |
+ .min_w_0() |
|
| 116 |
+ .p_3() |
|
| 117 |
+ .text_sm() |
|
| 118 |
+ .text_color(colors.text_secondary) |
|
| 119 |
+ .whitespace_normal() |
|
| 120 |
+ .child(detail), |
|
| 121 |
+ ) |
|
| 122 |
+ }); |
|
| 123 |
+ |
|
| 124 |
+ let actions = self |
|
| 125 |
+ .actions |
|
| 126 |
+ .iter() |
|
| 127 |
+ .enumerate() |
|
| 128 |
+ .rev() |
|
| 129 |
+ .map(|(index, action)| {
|
|
| 130 |
+ let button = Button::new(("app-prompt-action", index))
|
|
| 131 |
+ .label(action.label().clone()) |
|
| 132 |
+ .on_click(cx.listener(move |prompt, _, _, cx| {
|
|
| 133 |
+ prompt.respond(index, cx); |
|
| 134 |
+ })); |
|
| 135 |
+ |
|
| 136 |
+ if action.is_cancel() {
|
|
| 137 |
+ button.outline() |
|
| 138 |
+ } else if index == 0 {
|
|
| 139 |
+ button.primary() |
|
| 140 |
+ } else {
|
|
| 141 |
+ button.secondary() |
|
| 142 |
+ } |
|
| 143 |
+ }); |
|
| 144 |
+ |
|
| 145 |
+ let dialog = div() |
|
| 146 |
+ .id("app-prompt")
|
|
| 147 |
+ .track_focus(&self.focus_handle) |
|
| 148 |
+ .capture_key_down(cx.listener(Self::handle_key_down)) |
|
| 149 |
+ .w(px(PROMPT_WIDTH)) |
|
| 150 |
+ .max_w_full() |
|
| 151 |
+ .rounded_xl() |
|
| 152 |
+ .border_1() |
|
| 153 |
+ .border_color(colors.border) |
|
| 154 |
+ .bg(colors.surface) |
|
| 155 |
+ .text_color(colors.text_primary) |
|
| 156 |
+ .shadow(vec![ |
|
| 157 |
+ BoxShadow {
|
|
| 158 |
+ color: hsla(0., 0., 0., 0.18), |
|
| 159 |
+ offset: point(px(0.), px(18.)), |
|
| 160 |
+ blur_radius: px(40.), |
|
| 161 |
+ spread_radius: px(-12.), |
|
| 162 |
+ inset: false, |
|
| 163 |
+ }, |
|
| 164 |
+ BoxShadow {
|
|
| 165 |
+ color: hsla(0., 0., 0., 0.1), |
|
| 166 |
+ offset: point(px(0.), px(6.)), |
|
| 167 |
+ blur_radius: px(14.), |
|
| 168 |
+ spread_radius: px(-6.), |
|
| 169 |
+ inset: false, |
|
| 170 |
+ }, |
|
| 171 |
+ ]) |
|
| 172 |
+ .overflow_hidden() |
|
| 173 |
+ .child( |
|
| 174 |
+ div() |
|
| 175 |
+ .w_full() |
|
| 176 |
+ .p_5() |
|
| 177 |
+ .pb_4() |
|
| 178 |
+ .flex() |
|
| 179 |
+ .flex_col() |
|
| 180 |
+ .gap_4() |
|
| 181 |
+ .child(header) |
|
| 182 |
+ .children(body), |
|
| 183 |
+ ) |
|
| 184 |
+ .child( |
|
| 185 |
+ div() |
|
| 186 |
+ .w_full() |
|
| 187 |
+ .px_5() |
|
| 188 |
+ .py_3() |
|
| 189 |
+ .border_t_1() |
|
| 190 |
+ .border_color(colors.border) |
|
| 191 |
+ .bg(colors.surface_subtle) |
|
| 192 |
+ .flex() |
|
| 193 |
+ .items_center() |
|
| 194 |
+ .justify_end() |
|
| 195 |
+ .gap_2() |
|
| 196 |
+ .children(actions), |
|
| 197 |
+ ); |
|
| 198 |
+ |
|
| 199 |
+ div() |
|
| 200 |
+ .size_full() |
|
| 201 |
+ .absolute() |
|
| 202 |
+ .top_0() |
|
| 203 |
+ .left_0() |
|
| 204 |
+ .p_6() |
|
| 205 |
+ .bg(opaque_grey(0.05, 0.62)) |
|
| 206 |
+ .flex() |
|
| 207 |
+ .items_center() |
|
| 208 |
+ .justify_center() |
|
| 209 |
+ .on_any_mouse_down(|_, _, cx| cx.stop_propagation()) |
|
| 210 |
+ .child(dialog) |
|
| 211 |
+ } |
|
| 212 |
+} |
|
| 213 |
+ |
|
| 214 |
+impl EventEmitter<PromptResponse> for AppPrompt {}
|
|
| 215 |
+ |
|
| 216 |
+impl Focusable for AppPrompt {
|
|
| 217 |
+ fn focus_handle(&self, _: &App) -> FocusHandle {
|
|
| 218 |
+ self.focus_handle.clone() |
|
| 219 |
+ } |
|
| 220 |
+} |
|
| 221 |
+ |
|
| 222 |
+#[cfg(test)] |
|
| 223 |
+mod tests {
|
|
| 224 |
+ use super::*; |
|
| 225 |
+ |
|
| 226 |
+ #[test] |
|
| 227 |
+ fn prompt_keyboard_actions_use_confirm_and_cancel_semantics() {
|
|
| 228 |
+ let actions = [ |
|
| 229 |
+ PromptButton::new("Continue"),
|
|
| 230 |
+ PromptButton::Cancel("Cancel".into()),
|
|
| 231 |
+ ]; |
|
| 232 |
+ |
|
| 233 |
+ assert_eq!( |
|
| 234 |
+ actions.iter().position(|action| !action.is_cancel()), |
|
| 235 |
+ Some(0) |
|
| 236 |
+ ); |
|
| 237 |
+ assert_eq!(actions.iter().position(PromptButton::is_cancel), Some(1)); |
|
| 238 |
+ } |
|
| 239 |
+} |
|
| 0 | 240 |