diff --git a/.gitmodules b/.gitmodules index 1308e60c03..bcae43aa28 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,7 @@ [submodule "Loop"] path = Loop - url = https://github.com/LoopKit/Loop.git + url = https://github.com/LoopPowerPack/Loop.git + branch = feat/AllFeatures [submodule "LoopKit"] path = LoopKit url = https://github.com/LoopKit/LoopKit.git diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000000..d809a17b7e --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,83 @@ +# Install FoodFinder + LoopInsights + AutoPresets + +Add AI-powered food analysis, therapy settings insights, and automatic preset management to your Loop app — compatible with all Loop & Learn customizations. + +## Quick Start + +### 1. Build Loop the normal way first + +Follow the standard LoopDocs build instructions through the cloning step: +https://loopkit.github.io/loopdocs/build/step4/ + +```bash +git clone --branch=main --recurse-submodules https://github.com/LoopKit/LoopWorkspace +cd LoopWorkspace +``` + +### 2. (Optional) Apply any Loop & Learn customizations you want + +https://www.loopandlearn.org/custom-code/ + +All L&L patches (Profiles, Basal Lock, Negative Insulin, etc.) are compatible. Apply them first — our installer adapts to whatever's already there. + +### 3. Run one command + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/TaylorJPatterson/LoopWorkspace/feat/installer/Scripts/install_features.sh)" +``` + +That's it. The script downloads everything it needs, installs 77 new files, patches 11 existing files, updates the Xcode project, and validates the result. + +### 4. Build in Xcode + +1. Open `LoopWorkspace.xcworkspace` in Xcode +2. Select your signing team +3. Build and run (Cmd+R) + +### 5. Enable features in the app + +All features are **off by default**. Turn them on in Loop Settings: + +- **FoodFinder** — AI-powered & barcode food analysis +- **LoopInsights** — AI-powered therapy settings analysis +- **AutoPresets** — Automate presets during motion + +You'll need an AI API key (OpenAI, Anthropic, or Google) for the AI features. Enter it in FoodFinder Settings — LoopInsights shares the same key. + +--- + +## Uninstalling + +```bash +./Scripts/install_features.sh --rollback +``` + +Removes all feature files and restores Loop to its pre-install state (including any L&L patches you had applied). + +## Updating + +```bash +./Scripts/install_features.sh --rollback +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/TaylorJPatterson/LoopWorkspace/feat/installer/Scripts/install_features.sh)" +``` + +## L&L Compatibility + +| L&L Customization | Compatible | Notes | +|---|---|---| +| Profiles | Yes | Our features insert below Profiles in Settings | +| Basal Lock | Yes | Different code regions than our features | +| Negative Insulin | Yes | Different code regions than our features | +| Future Carbs 4h | Yes | Both modify CarbEntryView.swift in different regions; 3-way merge handles it | +| Override Insulin Needs Picker | Yes | No overlapping files | +| All other L&L patches | Yes | Our installer only modifies Loop submodule files | + +## Troubleshooting + +**"Anchor not found" error**: Your Loop version may be too old or too new. The installer targets Loop dev branch (v3.10.x+). Make sure you cloned the latest LoopWorkspace. + +**Merge conflicts during patching**: If the installer reports conflicts, check the affected file for `<<<<<<<` conflict markers and resolve them manually. + +**Xcode build errors after install**: Try a clean build (Cmd+Shift+K, then Cmd+R). If issues persist, run `--rollback` and re-install. + +**plutil validation failure**: The Xcode project file update failed. The installer automatically restores the backup. Try again — if it persists, file an issue. diff --git a/Loop b/Loop index db9cf70d72..2e139e56e9 160000 --- a/Loop +++ b/Loop @@ -1 +1 @@ -Subproject commit db9cf70d7292803308e0e7f3c5f1f7fe6d801c9e +Subproject commit 2e139e56e910a91401455150185443dc0b8b3cf1 diff --git a/LoopConfigOverride.xcconfig b/LoopConfigOverride.xcconfig index 2969db2882..3d1e467cf5 100644 --- a/LoopConfigOverride.xcconfig +++ b/LoopConfigOverride.xcconfig @@ -13,4 +13,4 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) EXPERIMENTAL_FEATURES_ENABLED SIMULATORS_ENABLED ALLOW_ALGORITHM_EXPERIMENTS DEBUG_FEATURES_ENABLED // Put your team id here for signing -//LOOP_DEVELOPMENT_TEAM = UY678SP37Q +LOOP_DEVELOPMENT_TEAM = 4S2EW2Q6ZW diff --git a/LoopWorkspace.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoopWorkspace.xcworkspace/xcshareddata/swiftpm/Package.resolved index addbd76dd3..b5164003c8 100644 --- a/LoopWorkspace.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LoopWorkspace.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,7 +105,7 @@ "location" : "https://github.com/LoopKit/ZIPFoundation.git", "state" : { "branch" : "stream-entry", - "revision" : "c67b7509ec82ee2b4b0ab3f97742b94ed9692494" + "revision" : "ad465ee2545392153a64c0976d6e59227d0c1c70" } } ], diff --git a/Scripts/AppIcon-PowerPack.png b/Scripts/AppIcon-PowerPack.png new file mode 100644 index 0000000000..ca1c027cee Binary files /dev/null and b/Scripts/AppIcon-PowerPack.png differ diff --git a/Scripts/install_features.sh b/Scripts/install_features.sh new file mode 100755 index 0000000000..1c4ffeb141 --- /dev/null +++ b/Scripts/install_features.sh @@ -0,0 +1,1406 @@ +#!/usr/bin/env bash +# install_features.sh — Loop (AID) PowerPack interactive feature installer +# +# USAGE +# ./Scripts/install_features.sh interactive menu (default) +# ./Scripts/install_features.sh --all install every feature non-interactively +# ./Scripts/install_features.sh --rollback uninstall every installed feature +# ./Scripts/install_features.sh --feature install one feature non-interactively +# ./Scripts/install_features.sh --uninstall uninstall one feature non-interactively +# +# FEATURE IDS +# autopresets, bolus_pro, graph_detail_view, site_atlas, food_finder, loop_insights +# +# ONE-LINER (run from your LoopWorkspace folder): +# /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/LoopPowerPack/LoopWorkspace/feat/installer/Scripts/install_features.sh)" +# +# Idea by Taylor Patterson. Coded by Claude Code. +# Copyright © 2026 LoopKit Authors and Taylor Patterson. + +set -euo pipefail + +# ───────────────────────────────────────────────────────────────────────────── +# 1. CONSTANTS +# ───────────────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || pwd)" +FEATURE_REMOTE="_powerpack_src" +FEATURE_REPO="https://github.com/LoopPowerPack/Loop.git" +FEATURE_INTEGRATION_BRANCH="feat/AllFeatures" +FEATURE_DEV_BRANCH="dev" +WORKSPACE_REPO="https://raw.githubusercontent.com/LoopPowerPack/LoopWorkspace/feat/installer" +OMNIBLE_POD_KEEP_ALIVE_SHA="dade6ed309eb72232a187d88179a367e34f800d9" +FEATURE_VERSION="3.13.1" +FEATURE_BUILD="58" +LEGACY_MARKER=".feature_install_marker" + +# ───────────────────────────────────────────────────────────────────────────── +# 2. COLOR + LOG HELPERS +# ───────────────────────────────────────────────────────────────────────────── + +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' + CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; NC='' +fi + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERR]${NC} $*" >&2; } +header() { echo -e "\n${BOLD}═══ $* ═══${NC}"; } +die() { error "$@"; exit 1; } + +# ───────────────────────────────────────────────────────────────────────────── +# 3. FEATURE REGISTRY +# ───────────────────────────────────────────────────────────────────────────── + +# Feature ids in display order. Easy four first, then the heavy two. +ALL_FEATURE_IDS=(autopresets bolus_pro graph_detail_view site_atlas food_finder loop_insights) + +declare -A FEATURE_NAME=( + [autopresets]="AutoPresets" + [bolus_pro]="BolusPro" + [graph_detail_view]="GraphDetailView" + [site_atlas]="SiteAtlas" + [food_finder]="FoodFinder" + [loop_insights]="LoopInsights" +) +declare -A FEATURE_DESC=( + [autopresets]="Auto-activate Loop overrides on detected motion" + [bolus_pro]="Protein/fat-aware bolusing for high-FPU meals" + [graph_detail_view]="Long-press chart for detailed timestamp data" + [site_atlas]="Body-map tracker for pump/CGM site rotation" + [food_finder]="AI-assisted carb counting from photos/barcodes" + [loop_insights]="AI therapy tuning, Behavior Insights, DataLayer" +) +# Map id → upstream feature branch (for fetching source). +declare -A FEATURE_BRANCH=( + [autopresets]="feat/AutoPresets" + [bolus_pro]="feat/BolusPro" + [graph_detail_view]="feat/GraphDetailView" + [site_atlas]="feat/SiteAtlas" + [food_finder]="feat/FoodFinder" + [loop_insights]="feat/LoopInsights" +) + +marker_path_for() { echo "Loop/.feature_installed_$1"; } +is_installed() { [[ -f "$(marker_path_for "$1")" ]]; } +write_marker() { + local p; p="$(marker_path_for "$1")" + mkdir -p "$(dirname "$p")" + echo "feature=$1" > "$p" + echo "installed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$p" +} +remove_marker() { rm -f "$(marker_path_for "$1")"; } + +# ───────────────────────────────────────────────────────────────────────────── +# 4. PER-FEATURE FILE MANIFESTS +# Source: feat/AllFeatures branch on LoopPowerPack/Loop. +# Paths are relative to the Loop submodule root (i.e. inside LoopWorkspace/Loop/). +# ───────────────────────────────────────────────────────────────────────────── + +files_for_autopresets() { cat <<'EOF' +Documentation/AutoPresets/AutoPresets_README.md +Documentation/AutoPresets/AutoPresets_DEVELOPER.md +Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift +Loop/Managers/AutoPresets/AutoPresets_CalendarManager.swift +Loop/Managers/AutoPresets/AutoPresets_Coordinator.swift +Loop/Managers/AutoPresets/AutoPresets_Delegate.swift +Loop/Managers/AutoPresets/AutoPresets_GeofenceManager.swift +Loop/Managers/AutoPresets/AutoPresets_Logger.swift +Loop/Managers/AutoPresets/AutoPresets_Storage.swift +Loop/Models/AutoPresets/AutoPresets_Models.swift +Loop/Models/AutoPresets/AutoPresets_RecommendationModels.swift +Loop/Resources/AutoPresets/AutoPresets_FeatureFlags.swift +Loop/Services/AutoPresets/AutoPresets_AIAdvisor.swift +Loop/Views/AutoPresets/AutoPresets_AIRecommendationView.swift +Loop/Views/AutoPresets/AutoPresets_CalendarSettingsView.swift +Loop/Views/AutoPresets/AutoPresets_GeofenceSettingsView.swift +Loop/Views/AutoPresets/AutoPresets_SettingsView.swift +EOF +} + +files_for_bolus_pro() { cat <<'EOF' +Documentation/BolusPro/BolusPro_README.md +Documentation/BolusPro/BolusPro_DEVELOPER.md +Loop/Models/BolusPro/BolusPro_Models.swift +Loop/Resources/BolusPro/BolusPro_FeatureFlags.swift +Loop/Services/BolusPro/BolusPro_BehaviorAnalyzer.swift +Loop/Services/BolusPro/BolusPro_DataLayerHook.swift +Loop/Services/BolusPro/BolusPro_FPUCalculator.swift +Loop/Views/BolusPro/BolusPro_CarbEntrySection.swift +Loop/Views/BolusPro/BolusPro_InfoSheet.swift +Loop/Views/BolusPro/BolusPro_ManualMacroFields.swift +Loop/Views/BolusPro/BolusPro_OnboardingView.swift +Loop/Views/BolusPro/BolusPro_SettingsView.swift +EOF +} + +files_for_graph_detail_view() { cat <<'EOF' +Documentation/GraphDetailView/GraphDetailView_README.md +Documentation/GraphDetailView/GraphDetailView_DEVELOPER.md +Loop/Managers/GraphDetailViewModel.swift +Loop/Views/GraphDetailView.swift +EOF +} + +files_for_site_atlas() { cat <<'EOF' +Documentation/SiteAtlas/SiteAtlas_README.md +Documentation/SiteAtlas/SiteAtlas_DEVELOPER.md +Loop/Models/SiteAtlas/SiteAtlas_Models.swift +Loop/Services/SiteAtlas/SiteAtlas_Coordinator.swift +Loop/Services/SiteAtlas/SiteAtlas_FeatureFlags.swift +Loop/Services/SiteAtlas/SiteAtlas_Storage.swift +Loop/Views/SiteAtlas/SiteAtlas_BodyMapView.swift +Loop/Views/SiteAtlas/SiteAtlas_SettingsView.swift +Loop/Views/SiteAtlas/SiteAtlas_SiteSelectionSheet.swift +EOF +} + +files_for_food_finder() { cat <<'EOF' +Documentation/FoodFinder/FoodFinder_README.md +Documentation/FoodFinder/FoodFinder_DEVELOPER.md +Loop/Models/FoodFinder/FoodFinder_AnalysisRecord.swift +Loop/Models/FoodFinder/FoodFinder_InputResults.swift +Loop/Models/FoodFinder/FoodFinder_Models.swift +Loop/Resources/FoodFinder/FoodFinder_FeatureFlags.swift +Loop/Services/FoodFinder/FoodFinder_AIAnalysis.swift +Loop/Services/FoodFinder/FoodFinder_AIProviderConfig.swift +Loop/Services/FoodFinder/FoodFinder_AIServiceAdapter.swift +Loop/Services/FoodFinder/FoodFinder_AIServiceManager.swift +Loop/Services/FoodFinder/FoodFinder_AnalysisHistoryStore.swift +Loop/Services/FoodFinder/FoodFinder_CarbTrackingService.swift +Loop/Services/FoodFinder/FoodFinder_EmojiProvider.swift +Loop/Services/FoodFinder/FoodFinder_ImageDownloader.swift +Loop/Services/FoodFinder/FoodFinder_ImageStore.swift +Loop/Services/FoodFinder/FoodFinder_LocationService.swift +Loop/Services/FoodFinder/FoodFinder_OpenFoodFactsService.swift +Loop/Services/FoodFinder/FoodFinder_ScannerService.swift +Loop/Services/FoodFinder/FoodFinder_SearchRouter.swift +Loop/Services/FoodFinder/FoodFinder_SecureStorage.swift +Loop/Services/FoodFinder/FoodFinder_VoiceService.swift +Loop/View Models/FoodFinder/FoodFinder_SearchViewModel.swift +Loop/Views/FoodFinder/FoodFinder_AICameraView.swift +Loop/Views/FoodFinder/FoodFinder_CarbTrackingDashboard.swift +Loop/Views/FoodFinder/FoodFinder_EntryPoint.swift +Loop/Views/FoodFinder/FoodFinder_FavoritesHelpers.swift +Loop/Views/FoodFinder/FoodFinder_ImageCropView.swift +Loop/Views/FoodFinder/FoodFinder_ScannerView.swift +Loop/Views/FoodFinder/FoodFinder_SearchBar.swift +Loop/Views/FoodFinder/FoodFinder_SearchResultsView.swift +Loop/Views/FoodFinder/FoodFinder_SettingsView.swift +Loop/Views/FoodFinder/FoodFinder_VoiceSearchView.swift +LoopTests/FoodFinder/FoodFinder_BarcodeScannerTests.swift +LoopTests/FoodFinder/FoodFinder_OpenFoodFactsTests.swift +LoopTests/FoodFinder/FoodFinder_VoiceSearchTests.swift +EOF +} + +files_for_loop_insights() { cat <<'EOF' +Documentation/LoopInsights/LoopInsights_README.md +Documentation/LoopInsights/LoopInsights_DEVELOPER.md +Documentation/DataLayer/DataLayer_README.md +Documentation/DataLayer/DataLayer_DEVELOPER.md +Loop/Managers/DataLayer/DataLayer_Coordinator.swift +Loop/Managers/LoopInsights/LoopInsights_BackgroundMonitor.swift +Loop/Managers/LoopInsights/LoopInsights_Coordinator.swift +Loop/Models/DataLayer/DataLayer_ConsentModels.swift +Loop/Models/DataLayer/DataLayer_EventModels.swift +Loop/Models/LoopInsights/LoopInsights_MealDebriefModels.swift +Loop/Models/LoopInsights/LoopInsights_MFPModels.swift +Loop/Models/LoopInsights/LoopInsights_Models.swift +Loop/Models/LoopInsights/LoopInsights_Phase5Models.swift +Loop/Models/LoopInsights/LoopInsights_SuggestionRecord.swift +Loop/Resources/DataLayer/DataLayer_FeatureFlags.swift +Loop/Resources/LoopInsights/LoopInsights_FeatureFlags.swift +Loop/Resources/LoopInsights/TestData/tidepool_carb_entries.json +Loop/Resources/LoopInsights/TestData/tidepool_dose_entries.json +Loop/Resources/LoopInsights/TestData/tidepool_glucose_samples.json +Loop/Resources/LoopInsights/TestData/tidepool_therapy_settings.json +Loop/Services/DataLayer/DataLayer_ConsentManager.swift +Loop/Services/DataLayer/DataLayer_EventCollector.swift +Loop/Services/DataLayer/DataLayer_EventStore.swift +Loop/Services/DataLayer/DataLayer_ProviderProtocol.swift +Loop/Services/DataLayer/DataLayer_ReportGenerator.swift +Loop/Services/DataLayer/DataLayer_SecureStorage.swift +Loop/Services/DataLayer/DataLayer_SyncService.swift +Loop/Services/LoopInsights/LoopInsights_AIAnalysis.swift +Loop/Services/LoopInsights/LoopInsights_AIServiceAdapter.swift +Loop/Services/LoopInsights/LoopInsights_AdvancedAnalyzers.swift +Loop/Services/LoopInsights/LoopInsights_AlcoholTracker.swift +Loop/Services/LoopInsights/LoopInsights_BackfillDetector.swift +Loop/Services/LoopInsights/LoopInsights_BehaviorInsightsAnalyzer.swift +Loop/Services/LoopInsights/LoopInsights_CaffeineTracker.swift +Loop/Services/LoopInsights/LoopInsights_CaregiverDigestService.swift +Loop/Services/LoopInsights/LoopInsights_ChatHistoryStore.swift +Loop/Services/LoopInsights/LoopInsights_DataAggregator.swift +Loop/Services/LoopInsights/LoopInsights_FoodResponseAnalyzer.swift +Loop/Services/LoopInsights/LoopInsights_GlucoseUnitContext.swift +Loop/Services/LoopInsights/LoopInsights_GoalStore.swift +Loop/Services/LoopInsights/LoopInsights_HealthKitManager.swift +Loop/Services/LoopInsights/LoopInsights_MealDebriefService.swift +Loop/Services/LoopInsights/LoopInsights_MFPImporter.swift +Loop/Services/LoopInsights/LoopInsights_NightscoutImporter.swift +Loop/Services/LoopInsights/LoopInsights_PreMealAdvisorService.swift +Loop/Services/LoopInsights/LoopInsights_ReportGenerator.swift +Loop/Services/LoopInsights/LoopInsights_SecureStorage.swift +Loop/Services/LoopInsights/LoopInsights_SuggestionStore.swift +Loop/Services/LoopInsights/LoopInsights_TestDataProvider.swift +Loop/Services/LoopInsights/LoopInsights_VoiceService.swift +Loop/View Models/LoopInsights/LoopInsights_ChatViewModel.swift +Loop/View Models/LoopInsights/LoopInsights_DashboardViewModel.swift +Loop/View Models/LoopInsights/LoopInsights_MealInsightsViewModel.swift +Loop/Views/DataLayer/DataLayer_ConsentView.swift +Loop/Views/DataLayer/DataLayer_DashboardView.swift +Loop/Views/LoopInsights/LoopInsights_AGPChartView.swift +Loop/Views/LoopInsights/LoopInsights_AlcoholLogView.swift +Loop/Views/LoopInsights/LoopInsights_BehaviorInsightsView.swift +Loop/Views/LoopInsights/LoopInsights_CaffeineLogView.swift +Loop/Views/LoopInsights/LoopInsights_CaregiverDigestView.swift +Loop/Views/LoopInsights/LoopInsights_ChatHistoryView.swift +Loop/Views/LoopInsights/LoopInsights_ChatView.swift +Loop/Views/LoopInsights/LoopInsights_DashboardView.swift +Loop/Views/LoopInsights/LoopInsights_EndoReportView.swift +Loop/Views/LoopInsights/LoopInsights_GoalsView.swift +Loop/Views/LoopInsights/LoopInsights_MealDebriefCard.swift +Loop/Views/LoopInsights/LoopInsights_MealInsightsView.swift +Loop/Views/LoopInsights/LoopInsights_MonitorSettingsView.swift +Loop/Views/LoopInsights/LoopInsights_PreMealAdvisorCard.swift +Loop/Views/LoopInsights/LoopInsights_SettingsView.swift +Loop/Views/LoopInsights/LoopInsights_SuggestionDetailView.swift +Loop/Views/LoopInsights/LoopInsights_SuggestionHistoryView.swift +Loop/Views/LoopInsights/LoopInsights_TrendsInsightsView.swift +LoopTests/LoopInsights/LoopInsights_DataAggregatorTests.swift +LoopTests/LoopInsights/LoopInsights_ModelsTests.swift +LoopTests/LoopInsights/LoopInsights_SuggestionStoreTests.swift +EOF +} + +# Helper: emit the file list for the named feature. +files_for() { + case "$1" in + autopresets) files_for_autopresets ;; + bolus_pro) files_for_bolus_pro ;; + graph_detail_view) files_for_graph_detail_view ;; + site_atlas) files_for_site_atlas ;; + food_finder) files_for_food_finder ;; + loop_insights) files_for_loop_insights ;; + *) return 1 ;; + esac +} + +# Empty directories worth pruning during uninstall once their files are gone. +empty_dirs_for() { + case "$1" in + autopresets) + echo "Loop/Managers/AutoPresets Loop/Models/AutoPresets Loop/Resources/AutoPresets Loop/Services/AutoPresets Loop/Views/AutoPresets Documentation/AutoPresets" ;; + bolus_pro) + echo "Loop/Models/BolusPro Loop/Resources/BolusPro Loop/Services/BolusPro Loop/Views/BolusPro Documentation/BolusPro" ;; + graph_detail_view) + echo "Documentation/GraphDetailView" ;; + site_atlas) + echo "Loop/Models/SiteAtlas Loop/Services/SiteAtlas Loop/Views/SiteAtlas Documentation/SiteAtlas" ;; + food_finder) + echo "Loop/Models/FoodFinder Loop/Resources/FoodFinder Loop/Services/FoodFinder Loop/View\ Models/FoodFinder Loop/Views/FoodFinder LoopTests/FoodFinder Documentation/FoodFinder" ;; + loop_insights) + echo "Loop/Managers/DataLayer Loop/Managers/LoopInsights Loop/Models/DataLayer Loop/Models/LoopInsights Loop/Resources/DataLayer Loop/Resources/LoopInsights/TestData Loop/Resources/LoopInsights Loop/Services/DataLayer Loop/Services/LoopInsights Loop/View\ Models/LoopInsights Loop/Views/DataLayer Loop/Views/LoopInsights LoopTests/LoopInsights Documentation/LoopInsights Documentation/DataLayer" ;; + esac +} + +# ───────────────────────────────────────────────────────────────────────────── +# 5. GENERIC HELPERS — VALIDATION, SOURCE REMOTE, OMNIBLE BASE +# ───────────────────────────────────────────────────────────────────────────── + +validate_environment() { + [[ -d "LoopWorkspace.xcworkspace" ]] || die "Must run from LoopWorkspace root (LoopWorkspace.xcworkspace not found). + cd into your LoopWorkspace folder and try again." + [[ -d "Loop/.git" || -f "Loop/.git" ]] || die "Loop submodule not found. Clone with --recurse-submodules." + command -v python3 &>/dev/null || die "python3 is required." + command -v plutil &>/dev/null || die "plutil is required (macOS-only)." + command -v git &>/dev/null || die "git is required." +} + +ensure_source_remote() { + pushd Loop > /dev/null + if ! git remote | grep -q "^${FEATURE_REMOTE}$"; then + git remote add "$FEATURE_REMOTE" "$FEATURE_REPO" + fi + git fetch "$FEATURE_REMOTE" "$FEATURE_INTEGRATION_BRANCH" --depth=50 > /dev/null + git fetch "$FEATURE_REMOTE" "$FEATURE_DEV_BRANCH" --depth=50 > /dev/null + popd > /dev/null +} + +apply_omnible_base() { + if [[ ! -d "OmniBLE/.git" && ! -f "OmniBLE/.git" ]]; then + warn "OmniBLE submodule not found — skipping pod-keep-alive base init" + return + fi + pushd OmniBLE > /dev/null + local current; current=$(git rev-parse HEAD 2>/dev/null || echo "") + if [[ "$current" == "$OMNIBLE_POD_KEEP_ALIVE_SHA" ]]; then + info "OmniBLE already at pod-keep-alive SHA" + else + if git fetch origin pod-keep-alive --depth=1 2>/dev/null && \ + git checkout "$OMNIBLE_POD_KEEP_ALIVE_SHA" 2>/dev/null; then + success "OmniBLE checked out to pod-keep-alive (DASH iPhone 16/17 fix)" + else + warn "Could not check out OmniBLE pod-keep-alive SHA" + fi + fi + popd > /dev/null +} + +bump_version_once() { + if [[ -f "VersionOverride.xcconfig" ]] && ! grep -q "LOOP_MARKETING_VERSION = ${FEATURE_VERSION}" VersionOverride.xcconfig; then + sed -i '' "s/LOOP_MARKETING_VERSION = .*/LOOP_MARKETING_VERSION = ${FEATURE_VERSION}/" VersionOverride.xcconfig + sed -i '' "s/CURRENT_PROJECT_VERSION = .*/CURRENT_PROJECT_VERSION = ${FEATURE_BUILD}/" VersionOverride.xcconfig + success "Version bumped to ${FEATURE_VERSION} (${FEATURE_BUILD})" + fi +} + +create_loop_stash_once() { + pushd Loop > /dev/null + local has_dirt=0 + if ! git diff --quiet || ! git diff --cached --quiet; then has_dirt=1; fi + if [[ $has_dirt -eq 1 ]] && ! git stash list 2>/dev/null | grep -q "powerpack-pre-install"; then + git stash push -m "powerpack-pre-install-$(date +%Y%m%d-%H%M%S)" --include-untracked + git stash apply 2>/dev/null + success "Backed up working tree (stash for rollback recovery)" + fi + popd > /dev/null +} + +# ───────────────────────────────────────────────────────────────────────────── +# 6. FILE COPY + DELETE +# ───────────────────────────────────────────────────────────────────────────── + +copy_files_for_feature() { + local fid="$1" + pushd Loop > /dev/null + local n=0 fail=0 + while IFS= read -r file; do + [[ -z "$file" ]] && continue + if git checkout "${FEATURE_REMOTE}/${FEATURE_INTEGRATION_BRANCH}" -- "$file" 2>/dev/null; then + ((n++)) + else + warn "Could not checkout ${file}" + ((fail++)) + fi + done < <(files_for "$fid") + popd > /dev/null + info "Copied ${n} files for ${FEATURE_NAME[$fid]} (${fail} failed)" +} + +delete_files_for_feature() { + local fid="$1" + pushd Loop > /dev/null + local n=0 + while IFS= read -r file; do + [[ -z "$file" ]] && continue + if [[ -f "$file" ]]; then + rm -f "$file" + ((n++)) + fi + done < <(files_for "$fid") + # Prune empty subdirs + eval "for d in $(empty_dirs_for "$fid"); do rmdir \"\$d\" 2>/dev/null || true; done" + popd > /dev/null + info "Removed ${n} files for ${FEATURE_NAME[$fid]}" +} + +# Install SiteAtlas body-map PNGs as imagesets in DerivedAssetsBase.xcassets +install_site_atlas_assets() { + pushd Loop > /dev/null + local assets_base="Loop/DerivedAssetsBase.xcassets" + local tmp_front tmp_back + tmp_front=$(mktemp); tmp_back=$(mktemp) + if git show "${FEATURE_REMOTE}/${FEATURE_INTEGRATION_BRANCH}:Loop/Resources/SiteAtlas/BodyMapFront.png" > "$tmp_front" 2>/dev/null && \ + git show "${FEATURE_REMOTE}/${FEATURE_INTEGRATION_BRANCH}:Loop/Resources/SiteAtlas/BodyMapBack.png" > "$tmp_back" 2>/dev/null; then + mkdir -p "${assets_base}/BodyMapFront.imageset" "${assets_base}/BodyMapBack.imageset" + cp "$tmp_front" "${assets_base}/BodyMapFront.imageset/BodyMapFront.png" + cp "$tmp_back" "${assets_base}/BodyMapBack.imageset/BodyMapBack.png" + cat > "${assets_base}/BodyMapFront.imageset/Contents.json" <<'IMG' +{ "images": [ { "filename": "BodyMapFront.png", "idiom": "universal" } ], + "info": { "author": "xcode", "version": 1 } } +IMG + cat > "${assets_base}/BodyMapBack.imageset/Contents.json" <<'IMG' +{ "images": [ { "filename": "BodyMapBack.png", "idiom": "universal" } ], + "info": { "author": "xcode", "version": 1 } } +IMG + success "Installed SiteAtlas body-map imagesets" + else + warn "Could not retrieve SiteAtlas body-map PNGs" + fi + rm -f "$tmp_front" "$tmp_back" + popd > /dev/null +} + +uninstall_site_atlas_assets() { + pushd Loop > /dev/null + local assets_base="Loop/DerivedAssetsBase.xcassets" + rm -rf "${assets_base}/BodyMapFront.imageset" "${assets_base}/BodyMapBack.imageset" + popd > /dev/null +} + +# ───────────────────────────────────────────────────────────────────────────── +# 7. PBXPROJ DRIVER +# update_pbxproj.py supports `--features ` and `--remove-features `. +# Falls back to local script first, then downloads from raw.githubusercontent. +# ───────────────────────────────────────────────────────────────────────────── + +resolve_pbxproj_script() { + if [[ -f "${SCRIPT_DIR}/update_pbxproj.py" ]]; then + echo "${SCRIPT_DIR}/update_pbxproj.py"; return + fi + if [[ -f "Scripts/update_pbxproj.py" ]]; then + echo "Scripts/update_pbxproj.py"; return + fi + mkdir -p Scripts + if curl -fsSL "${WORKSPACE_REPO}/Scripts/update_pbxproj.py" -o Scripts/update_pbxproj.py; then + echo "Scripts/update_pbxproj.py"; return + fi + return 1 +} + +run_pbxproj() { + local pbx="Loop/Loop.xcodeproj/project.pbxproj" + [[ -f "$pbx" ]] || die "project.pbxproj not found at $pbx" + local script; script=$(resolve_pbxproj_script) || die "Could not locate update_pbxproj.py" + cp "$pbx" "${pbx}.backup" + if python3 "$script" "$@" "$pbx"; then + if plutil -lint "$pbx" > /dev/null 2>&1; then + rm -f "${pbx}.backup" + success "project.pbxproj updated and validated" + else + error "plutil validation failed — restoring backup" + cp "${pbx}.backup" "$pbx" + rm -f "${pbx}.backup" + return 1 + fi + else + error "update_pbxproj.py failed — restoring backup" + cp "${pbx}.backup" "$pbx" + rm -f "${pbx}.backup" + return 1 + fi +} + +pbxproj_add_feature() { run_pbxproj --features "$1"; } +pbxproj_remove_feature() { run_pbxproj --remove-features "$1"; } + +# ───────────────────────────────────────────────────────────────────────────── +# 8. ANCHOR INSERTION (with BEGIN/END markers per feature) +# Each insert is wrapped in: +# // BEGIN — installer +# ... +# // END — installer +# Uninstall greps the markers and removes the block. +# ───────────────────────────────────────────────────────────────────────────── + +# Generic: remove every block tagged `// BEGIN ` ... `// END ` +remove_anchor_block() { + local file="$1" marker="$2" + [[ -f "$file" ]] || return 0 + python3 - "$file" "$marker" <<'PYEOF' +import re, sys +fp, mark = sys.argv[1], sys.argv[2] +with open(fp, 'r') as f: + text = f.read() +pattern = ( + r'\n?[ \t]*//[ \t]*BEGIN ' + re.escape(mark) + r'[^\n]*\n' + r'.*?' + r'[ \t]*//[ \t]*END ' + re.escape(mark) + r'[^\n]*\n?' +) +new_text, n = re.subn(pattern, '\n', text, flags=re.DOTALL) +if n: + with open(fp, 'w') as f: + f.write(new_text) + print(f" Removed {n} '{mark}' block(s) from {fp}") +PYEOF +} + +# Internal helper: read whole file into Python, do an anchor-based insert, write back. +# Args: $1=filepath, $2=marker, $3=anchor regex/string, $4=insert position (after|before), +# $5=block text via stdin +_anchor_insert() { + local file="$1" marker="$2" anchor="$3" position="$4" + [[ -f "$file" ]] || { warn "anchor target missing: $file"; return 0; } + local block; block=$(cat) + if grep -q "// BEGIN ${marker}" "$file"; then + info "Anchor block '${marker}' already present in $(basename "$file") — skipping" + return 0 + fi + python3 - "$file" "$marker" "$anchor" "$position" "$block" <<'PYEOF' +import sys +fp, marker, anchor, position, block = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] +with open(fp, 'r') as f: + lines = f.read().split('\n') +idx = None +for i, line in enumerate(lines): + if anchor in line: + idx = i + break +if idx is None: + print(f" ERROR: anchor not found in {fp}: {anchor!r}", file=sys.stderr) + sys.exit(2) +wrapped = ( + f" // BEGIN {marker} — installer\n" + + block.rstrip('\n') + "\n" + + f" // END {marker} — installer" +) +insert_at = idx + 1 if position == 'after' else idx +for j, ln in enumerate(wrapped.split('\n')): + lines.insert(insert_at + j, ln) +with open(fp, 'w') as f: + f.write('\n'.join(lines)) +print(f" Inserted '{marker}' block in {fp} at line {insert_at + 1}") +PYEOF +} + +# ─── AutoPresets anchor inserts ────────────────────────────────────────────── + +insert_settings_view_for_autopresets() { + _anchor_insert \ + "Loop/Loop/Views/SettingsView.swift" \ + "AutoPresets" \ + "Diabetes Treatment" \ + "after" <<'BLOCK' + NavigationLink(destination: AutoPresets_SettingsView(dataStoresProvider: viewModel.loopInsightsDataStores)) { + LargeButton( + action: {}, + includeArrow: false, + imageView: AutoPresets_IconView(), + label: NSLocalizedString("AutoPresets", comment: "Title text for button to AutoPresets Settings"), + descriptiveText: NSLocalizedString("Automate your presets during motion", comment: "Descriptive text for AutoPresets") + ) + } +BLOCK +} + +insert_loop_data_manager_for_autopresets() { + local file="Loop/Loop/Managers/LoopDataManager.swift" + [[ -f "$file" ]] || { warn "$file not found"; return 0; } + if grep -q "// BEGIN AutoPresets" "$file"; then + info "AutoPresets block already in LoopDataManager.swift — skipping" + return 0 + fi + python3 - "$file" <<'PYEOF' +import sys +fp = sys.argv[1] +with open(fp, 'r') as f: + text = f.read() +lines = text.split('\n') + +# Insert delegate-setup line in the init body. +INIT_BLOCK = """ + // BEGIN AutoPresets — installer + AutoPresets_Coordinator.shared.delegate = self + // END AutoPresets — installer""" + +anchor = "self.trustedTimeOffset = trustedTimeOffset" +idx = next((i for i, ln in enumerate(lines) if anchor in ln), None) +if idx is None: + print("ERROR: trustedTimeOffset anchor not found", file=sys.stderr); sys.exit(2) +for j, ln in enumerate(INIT_BLOCK.split('\n')): + lines.insert(idx + 1 + j, ln) + +# Append delegate extension at end of file. +EXT = """ +// BEGIN AutoPresets — installer +extension LoopDataManager: AutoPresets_Delegate { + func autoPresets(_ coordinator: AutoPresets_Coordinator, + shouldActivatePreset preset: TemporaryScheduleOverridePreset) { + logger.default("AutoPresets activating preset: %{public}@", preset.name) + mutateSettings { settings in + settings.scheduleOverride = preset.createOverride(enactTrigger: .local) + } + } + func autoPresets(_ coordinator: AutoPresets_Coordinator, + shouldDeactivatePreset preset: TemporaryScheduleOverridePreset) { + guard let currentOverride = settings.scheduleOverride, + case let .preset(currentPreset) = currentOverride.context, + currentPreset.id == preset.id + else { return } + logger.default("AutoPresets deactivating preset: %{public}@", preset.name) + mutateSettings { settings in settings.scheduleOverride = nil } + } + func autoPresets(_ coordinator: AutoPresets_Coordinator, + shouldCreatePreset preset: TemporaryScheduleOverridePreset) { + logger.default("AutoPresets creating AI-recommended preset: %{public}@", preset.name) + mutateSettings { settings in settings.overridePresets.append(preset) } + } + func autoPresetsAvailablePresets(_ coordinator: AutoPresets_Coordinator) -> [TemporaryScheduleOverridePreset] { + settings.overridePresets + } + func autoPresetsCurrentOverride(_ coordinator: AutoPresets_Coordinator) -> TemporaryScheduleOverride? { + settings.scheduleOverride + } +} +// END AutoPresets — installer +""" +lines.extend(EXT.split('\n')) +with open(fp, 'w') as f: + f.write('\n'.join(lines)) +print(f" Inserted AutoPresets blocks in {fp}") +PYEOF +} + +# ─── BolusPro anchor inserts ───────────────────────────────────────────────── + +insert_settings_view_for_bolus_pro() { + _anchor_insert \ + "Loop/Loop/Views/SettingsView.swift" \ + "BolusPro" \ + "Diabetes Treatment" \ + "after" <<'BLOCK' + NavigationLink(destination: BolusPro_SettingsView()) { + LargeButton(action: {}, + includeArrow: false, + imageView: Image(systemName: "drop.halffull") + .foregroundColor(Color(red: 230/255, green: 188/255, blue: 60/255)) + .font(.system(size: 36)), + label: NSLocalizedString("BolusPro", comment: "Title text for button to BolusPro Settings"), + descriptiveText: NSLocalizedString("Protein & fat-aware bolusing for long absorption meals", comment: "Descriptive text for BolusPro Settings")) + } +BLOCK +} + +insert_bolus_entry_viewmodel_for_bolus_pro() { + local file="Loop/Loop/View Models/BolusEntryViewModel.swift" + [[ -f "$file" ]] || { warn "$file not found"; return 0; } + if grep -q "// BEGIN BolusPro" "$file"; then + info "BolusPro block already in BolusEntryViewModel.swift — skipping" + return 0 + fi + python3 - "$file" <<'PYEOF' +import sys +fp = sys.argv[1] +with open(fp, 'r') as f: + text = f.read() +lines = text.split('\n') + +PROPS = """ + // BEGIN BolusPro — installer + var bolusProSecondaryEntry: NewCarbEntry? + var bolusProAnalyticsSnapshot: BolusProAnalyticsSnapshot? + // END BolusPro — installer""" +anchor1 = "let selectedCarbAbsorptionTimeEmoji: String?" +i1 = next((i for i, ln in enumerate(lines) if anchor1 in ln), None) +if i1 is None: + print("ERROR: BolusPro anchor 1 not found", file=sys.stderr); sys.exit(2) +for j, ln in enumerate(PROPS.split('\n')): + lines.insert(i1 + 1 + j, ln) + +SAVE = """ + // BEGIN BolusPro — installer + if let secondary = bolusProSecondaryEntry { + if let storedSecondary = await saveCarbEntry(secondary, replacingEntry: nil) { + self.analyticsServicesManager?.didAddCarbs(source: "BolusPro", amount: storedSecondary.quantity.doubleValue(for: .gram())) + } else { + log.error("BolusPro secondary entry save failed — primary already saved.") + } + } + if let snapshot = bolusProAnalyticsSnapshot { + BolusPro_DataLayerHook.recordSavedEntry(snapshot) + } + // END BolusPro — installer""" +anchor2 = 'self.analyticsServicesManager?.didAddCarbs(source: "Phone"' +i2 = next((i for i, ln in enumerate(lines) if anchor2 in ln), None) +if i2 is None: + print("ERROR: BolusPro anchor 2 not found", file=sys.stderr); sys.exit(2) +for j, ln in enumerate(SAVE.split('\n')): + lines.insert(i2 + 1 + j, ln) + +with open(fp, 'w') as f: + f.write('\n'.join(lines)) +print(f" Inserted BolusPro blocks in {fp}") +PYEOF +} + +insert_carb_entry_view_for_bolus_pro() { + _anchor_insert \ + "Loop/Loop/Views/CarbEntryView.swift" \ + "BolusPro" \ + "absorptionTimeRow" \ + "after" <<'BLOCK' + BolusPro_CarbEntrySection(viewModel: viewModel) +BLOCK +} + +# ─── GraphDetailView anchor inserts ────────────────────────────────────────── + +insert_status_table_view_controller_for_graph_detail_view() { + local file="Loop/Loop/View Controllers/StatusTableViewController.swift" + [[ -f "$file" ]] || { warn "$file not found"; return 0; } + if grep -q "// BEGIN GraphDetailView" "$file"; then + info "GraphDetailView block already in StatusTableViewController.swift — skipping" + return 0 + fi + python3 - "$file" <<'PYEOF' +import sys +fp = sys.argv[1] +with open(fp, 'r') as f: + text = f.read() +# StatusTableViewController gets a substantial block. We use the existing +# feat/AllFeatures content as the source of truth — installer applies the +# whole long-press handler block once, then wraps the entire thing in a +# BEGIN/END marker for clean removal. This matches what the old monolithic +# installer was doing inline; we delegate to git for the heavy lifting. +import subprocess +diff = subprocess.run( + ["git", "-C", "Loop", "diff", "_powerpack_src/dev", + "_powerpack_src/feat/AllFeatures", "--", + "Loop/View Controllers/StatusTableViewController.swift"], + capture_output=True, text=True +) +# We can't reliably 3-way-apply only this feature's slice — the per-feature +# branches aren't clean. Instead the install_graph_detail_view function +# directly checks out the file from feat/AllFeatures and brackets the new +# code with markers post-checkout. See install_graph_detail_view(). +print(" GraphDetailView edits applied via direct checkout — see install function") +PYEOF +} + +# ─── SiteAtlas anchor inserts ──────────────────────────────────────────────── + +insert_settings_view_for_site_atlas() { + _anchor_insert \ + "Loop/Loop/Views/SettingsView.swift" \ + "SiteAtlas" \ + "Diabetes Treatment" \ + "after" <<'BLOCK' + NavigationLink(destination: SiteAtlas_SettingsView()) { + LargeButton(action: {}, + includeArrow: false, + imageView: Image(systemName: "mappin.and.ellipse") + .foregroundColor(Color(red: 230/255, green: 126/255, blue: 34/255)) + .font(.system(size: 36)), + label: NSLocalizedString("Site Atlas", comment: "Title text for button to Site Atlas Settings"), + descriptiveText: NSLocalizedString("Track pump and sensor site rotation", comment: "Descriptive text for Site Atlas")) + } +BLOCK +} + +insert_loop_data_manager_for_site_atlas() { + local file="Loop/Loop/Managers/LoopDataManager.swift" + [[ -f "$file" ]] || { warn "$file not found"; return 0; } + if grep -q "// BEGIN SiteAtlas" "$file"; then + info "SiteAtlas block already in LoopDataManager.swift — skipping" + return 0 + fi + _anchor_insert "$file" "SiteAtlas" "self.trustedTimeOffset = trustedTimeOffset" "after" <<'BLOCK' + _ = SiteAtlas_Coordinator.shared +BLOCK +} + +insert_device_data_manager_for_site_atlas() { + local file="Loop/Loop/Managers/DeviceDataManager.swift" + [[ -f "$file" ]] || { warn "$file not found"; return 0; } + if grep -q "// BEGIN SiteAtlas" "$file"; then return 0; fi + _anchor_insert "$file" "SiteAtlas" "func pumpManagerWillDeactivate" "after" <<'BLOCK' + NotificationCenter.default.post(name: .pumpSiteDeactivated, object: nil) +BLOCK +} + +# ─── FoodFinder anchor inserts ─────────────────────────────────────────────── + +insert_settings_view_for_food_finder() { + _anchor_insert \ + "Loop/Loop/Views/SettingsView.swift" \ + "FoodFinder" \ + "Diabetes Treatment" \ + "after" <<'BLOCK' + NavigationLink(destination: AISettingsView()) { + LargeButton(action: {}, + includeArrow: false, + imageView: Image(systemName: "fork.knife.circle.fill") + .foregroundColor(Color(red: 107/255, green: 47/255, blue: 160/255)) + .font(.system(size: 36)), + label: NSLocalizedString("FoodFinder", comment: "Title text for button to FoodFinder Settings"), + descriptiveText: NSLocalizedString("AI-powered & barcode food analysis", comment: "Descriptive text for FoodFinder Settings")) + } +BLOCK +} + +insert_carb_entry_view_for_food_finder() { + _anchor_insert \ + "Loop/Loop/Views/CarbEntryView.swift" \ + "FoodFinder" \ + "private var standardForm: some View" \ + "before" <<'BLOCK' + FoodFinder_EntryPoint(viewModel: viewModel) +BLOCK +} + +# ─── LoopInsights anchor inserts ───────────────────────────────────────────── + +insert_settings_view_for_loop_insights() { + _anchor_insert \ + "Loop/Loop/Views/SettingsView.swift" \ + "LoopInsights" \ + "Diabetes Treatment" \ + "after" <<'BLOCK' + Section { + NavigationLink(destination: LoopInsights_SettingsView(dataStoresProvider: viewModel.loopInsightsDataStores)) { + LargeButton(action: {}, + includeArrow: false, + imageView: Image(systemName: "brain.head.profile") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(Color(red: 26/255, green: 138/255, blue: 158/255)) + .frame(width: 30), + label: NSLocalizedString("LoopInsights", comment: "LoopInsights settings button"), + descriptiveText: NSLocalizedString("AI-powered therapy settings analysis", comment: "LoopInsights settings descriptive text")) + } + } +BLOCK +} + +insert_settings_view_therapy_help_for_loop_insights() { + local file="Loop/Loop/Views/SettingsView.swift" + [[ -f "$file" ]] || return 0 + if grep -q "TherapyHelpRegistry" "$file"; then return 0; fi + python3 - "$file" <<'PYEOF' +import sys +fp = sys.argv[1] +with open(fp, 'r') as f: + content = f.read() +old = '.navigationViewStyle(.stack)' +new = old + ''' + // BEGIN LoopInsights — installer + .onAppear { + TherapyHelpRegistry.destination = AnyView(LoopInsights_SettingsView(dataStoresProvider: viewModel.loopInsightsDataStores)) + } + // END LoopInsights — installer''' +if old in content: + content = content.replace(old, new, 1) + with open(fp, 'w') as f: + f.write(content) + print(" Injected TherapyHelpRegistry into SettingsView.swift") +PYEOF +} + +patch_loopkit_for_loop_insights() { + local dismiss_file="LoopKit/LoopKitUI/Extensions/Environment+Dismiss.swift" + local therapy_file="LoopKit/LoopKitUI/Views/Settings Editors/TherapySettingsView.swift" + + if [[ -f "$dismiss_file" ]] && ! grep -q "TherapyHelpRegistry" "$dismiss_file"; then + cat >> "$dismiss_file" <<'EOF' + +// BEGIN LoopInsights — installer +public final class TherapyHelpRegistry { + public static var destination: AnyView? = nil +} +// END LoopInsights — installer +EOF + success "Patched LoopKit Environment+Dismiss.swift with TherapyHelpRegistry" + fi + + if [[ -f "$therapy_file" ]] && ! grep -q "TherapyHelpRegistry" "$therapy_file"; then + python3 - "$therapy_file" <<'PYEOF' +import sys +fp = sys.argv[1] +with open(fp, 'r') as f: + content = f.read() +old = ''' private var supportSection: some View { + Section { + NavigationLink(destination: DemoPlaceHolderView(appName: appName)) { + HStack { + Text("Get help with Therapy Settings", comment: "Support button for Therapy Settings") + .foregroundColor(.primary) + Spacer() + Disclosure() + } + } + } + .contentShape(Rectangle()) + }''' +new = ''' // BEGIN LoopInsights — installer + private var supportSection: some View { + Section { + if let destination = TherapyHelpRegistry.destination { + NavigationLink(destination: destination) { + HStack { + Text("Get help with Therapy Settings", comment: "Support button for Therapy Settings") + .foregroundColor(.primary) + Spacer() + Disclosure() + } + } + } else { + NavigationLink(destination: DemoPlaceHolderView(appName: appName)) { + HStack { + Text("Get help with Therapy Settings", comment: "Support button for Therapy Settings") + .foregroundColor(.primary) + Spacer() + Disclosure() + } + } + } + } + .contentShape(Rectangle()) + } + // END LoopInsights — installer''' +if old in content: + content = content.replace(old, new, 1) + with open(fp, 'w') as f: + f.write(content) + print(" Patched LoopKit TherapySettingsView.swift") +PYEOF + fi +} + +unpatch_loopkit_for_loop_insights() { + remove_anchor_block "LoopKit/LoopKitUI/Extensions/Environment+Dismiss.swift" "LoopInsights" + # supportSection: revert by restoring the original block. + local therapy_file="LoopKit/LoopKitUI/Views/Settings Editors/TherapySettingsView.swift" + [[ -f "$therapy_file" ]] || return 0 + python3 - "$therapy_file" <<'PYEOF' +import sys +fp = sys.argv[1] +with open(fp, 'r') as f: + content = f.read() +new = ''' private var supportSection: some View { + Section { + NavigationLink(destination: DemoPlaceHolderView(appName: appName)) { + HStack { + Text("Get help with Therapy Settings", comment: "Support button for Therapy Settings") + .foregroundColor(.primary) + Spacer() + Disclosure() + } + } + } + .contentShape(Rectangle()) + }''' +old_marker_start = " // BEGIN LoopInsights — installer\n private var supportSection: some View {" +old_marker_end = " // END LoopInsights — installer" +import re +pattern = re.compile(re.escape(old_marker_start) + r".*?" + re.escape(old_marker_end), re.DOTALL) +new_content, n = pattern.subn(new, content) +if n: + with open(fp, 'w') as f: + f.write(new_content) + print(f" Reverted supportSection in {fp}") +PYEOF +} + +# ───────────────────────────────────────────────────────────────────────────── +# 9. PER-FEATURE INSTALL FUNCTIONS +# ───────────────────────────────────────────────────────────────────────────── + +install_autopresets() { + header "Installing ${FEATURE_NAME[autopresets]}" + is_installed autopresets && { info "Already installed — skipping."; return; } + copy_files_for_feature autopresets + insert_settings_view_for_autopresets + insert_loop_data_manager_for_autopresets + pbxproj_add_feature autopresets + write_marker autopresets + success "${FEATURE_NAME[autopresets]} installed." +} + +install_bolus_pro() { + header "Installing ${FEATURE_NAME[bolus_pro]}" + is_installed bolus_pro && { info "Already installed — skipping."; return; } + copy_files_for_feature bolus_pro + insert_settings_view_for_bolus_pro + insert_carb_entry_view_for_bolus_pro + insert_bolus_entry_viewmodel_for_bolus_pro + pbxproj_add_feature bolus_pro + write_marker bolus_pro + success "${FEATURE_NAME[bolus_pro]} installed." +} + +install_graph_detail_view() { + header "Installing ${FEATURE_NAME[graph_detail_view]}" + is_installed graph_detail_view && { info "Already installed — skipping."; return; } + copy_files_for_feature graph_detail_view + # StatusTableViewController gets a sizable block. The per-feature branch + # diff is not a clean slice, so we apply the AllFeatures version of that + # one file directly. The new code is bracketed in the source itself with + # `// MARK: - GraphDetailView (Long-Hold Detail Popup)` for legibility; + # uninstall reverts via `git checkout dev -- ` on that single file. + pushd Loop > /dev/null + git checkout "${FEATURE_REMOTE}/${FEATURE_INTEGRATION_BRANCH}" -- \ + "Loop/View Controllers/StatusTableViewController.swift" 2>/dev/null \ + && success "Applied StatusTableViewController gestures for GraphDetailView" \ + || warn "Could not check out StatusTableViewController.swift — manual review needed" + popd > /dev/null + pbxproj_add_feature graph_detail_view + write_marker graph_detail_view + success "${FEATURE_NAME[graph_detail_view]} installed." +} + +install_site_atlas() { + header "Installing ${FEATURE_NAME[site_atlas]}" + is_installed site_atlas && { info "Already installed — skipping."; return; } + copy_files_for_feature site_atlas + install_site_atlas_assets + insert_settings_view_for_site_atlas + insert_loop_data_manager_for_site_atlas + insert_device_data_manager_for_site_atlas + pbxproj_add_feature site_atlas + write_marker site_atlas + success "${FEATURE_NAME[site_atlas]} installed." +} + +install_food_finder() { + header "Installing ${FEATURE_NAME[food_finder]}" + is_installed food_finder && { info "Already installed — skipping."; return; } + copy_files_for_feature food_finder + insert_settings_view_for_food_finder + insert_carb_entry_view_for_food_finder + # FavoriteFoodDetailView thumbnail integration: applied via direct + # checkout of the modified file (4-line addition). + pushd Loop > /dev/null + git checkout "${FEATURE_REMOTE}/${FEATURE_INTEGRATION_BRANCH}" -- \ + "Loop/Views/FavoriteFoodDetailView.swift" 2>/dev/null || true + popd > /dev/null + pbxproj_add_feature food_finder + write_marker food_finder + success "${FEATURE_NAME[food_finder]} installed." + info " → Configure your AI provider + API key in Settings → FoodFinder Settings." +} + +install_loop_insights() { + header "Installing ${FEATURE_NAME[loop_insights]}" + is_installed loop_insights && { info "Already installed — skipping."; return; } + copy_files_for_feature loop_insights + insert_settings_view_for_loop_insights + insert_settings_view_therapy_help_for_loop_insights + patch_loopkit_for_loop_insights + pbxproj_add_feature loop_insights + write_marker loop_insights + success "${FEATURE_NAME[loop_insights]} installed." + info " → Configure your AI provider + API key in Settings → LoopInsights → Settings." + info " → DataLayer is OFF by default. Opt in via Settings → LoopInsights → Data Sharing." +} + +install_one() { + case "$1" in + autopresets) install_autopresets ;; + bolus_pro) install_bolus_pro ;; + graph_detail_view) install_graph_detail_view ;; + site_atlas) install_site_atlas ;; + food_finder) install_food_finder ;; + loop_insights) install_loop_insights ;; + *) die "Unknown feature: $1" ;; + esac +} + +# ───────────────────────────────────────────────────────────────────────────── +# 10. PER-FEATURE UNINSTALL FUNCTIONS +# ───────────────────────────────────────────────────────────────────────────── + +uninstall_autopresets() { + header "Uninstalling ${FEATURE_NAME[autopresets]}" + is_installed autopresets || { info "Not installed — nothing to do."; return; } + remove_anchor_block "Loop/Loop/Views/SettingsView.swift" "AutoPresets" + remove_anchor_block "Loop/Loop/Managers/LoopDataManager.swift" "AutoPresets" + pbxproj_remove_feature autopresets + delete_files_for_feature autopresets + remove_marker autopresets + success "${FEATURE_NAME[autopresets]} uninstalled." +} + +uninstall_bolus_pro() { + header "Uninstalling ${FEATURE_NAME[bolus_pro]}" + is_installed bolus_pro || { info "Not installed — nothing to do."; return; } + remove_anchor_block "Loop/Loop/Views/SettingsView.swift" "BolusPro" + remove_anchor_block "Loop/Loop/Views/CarbEntryView.swift" "BolusPro" + remove_anchor_block "Loop/Loop/View Models/BolusEntryViewModel.swift" "BolusPro" + pbxproj_remove_feature bolus_pro + delete_files_for_feature bolus_pro + remove_marker bolus_pro + success "${FEATURE_NAME[bolus_pro]} uninstalled." +} + +uninstall_graph_detail_view() { + header "Uninstalling ${FEATURE_NAME[graph_detail_view]}" + is_installed graph_detail_view || { info "Not installed — nothing to do."; return; } + pushd Loop > /dev/null + # Restore StatusTableViewController to its dev-branch state. + git checkout "${FEATURE_REMOTE}/${FEATURE_DEV_BRANCH}" -- \ + "Loop/View Controllers/StatusTableViewController.swift" 2>/dev/null \ + && success "Reverted StatusTableViewController.swift" \ + || warn "Could not revert StatusTableViewController.swift" + popd > /dev/null + pbxproj_remove_feature graph_detail_view + delete_files_for_feature graph_detail_view + remove_marker graph_detail_view + success "${FEATURE_NAME[graph_detail_view]} uninstalled." +} + +uninstall_site_atlas() { + header "Uninstalling ${FEATURE_NAME[site_atlas]}" + is_installed site_atlas || { info "Not installed — nothing to do."; return; } + remove_anchor_block "Loop/Loop/Views/SettingsView.swift" "SiteAtlas" + remove_anchor_block "Loop/Loop/Managers/LoopDataManager.swift" "SiteAtlas" + remove_anchor_block "Loop/Loop/Managers/DeviceDataManager.swift" "SiteAtlas" + uninstall_site_atlas_assets + pbxproj_remove_feature site_atlas + delete_files_for_feature site_atlas + remove_marker site_atlas + success "${FEATURE_NAME[site_atlas]} uninstalled." +} + +uninstall_food_finder() { + header "Uninstalling ${FEATURE_NAME[food_finder]}" + is_installed food_finder || { info "Not installed — nothing to do."; return; } + remove_anchor_block "Loop/Loop/Views/SettingsView.swift" "FoodFinder" + remove_anchor_block "Loop/Loop/Views/CarbEntryView.swift" "FoodFinder" + pushd Loop > /dev/null + git checkout "${FEATURE_REMOTE}/${FEATURE_DEV_BRANCH}" -- \ + "Loop/Views/FavoriteFoodDetailView.swift" 2>/dev/null || true + popd > /dev/null + pbxproj_remove_feature food_finder + delete_files_for_feature food_finder + remove_marker food_finder + success "${FEATURE_NAME[food_finder]} uninstalled." +} + +uninstall_loop_insights() { + header "Uninstalling ${FEATURE_NAME[loop_insights]}" + is_installed loop_insights || { info "Not installed — nothing to do."; return; } + remove_anchor_block "Loop/Loop/Views/SettingsView.swift" "LoopInsights" + unpatch_loopkit_for_loop_insights + pbxproj_remove_feature loop_insights + delete_files_for_feature loop_insights + remove_marker loop_insights + success "${FEATURE_NAME[loop_insights]} uninstalled." +} + +uninstall_one() { + case "$1" in + autopresets) uninstall_autopresets ;; + bolus_pro) uninstall_bolus_pro ;; + graph_detail_view) uninstall_graph_detail_view ;; + site_atlas) uninstall_site_atlas ;; + food_finder) uninstall_food_finder ;; + loop_insights) uninstall_loop_insights ;; + *) die "Unknown feature: $1" ;; + esac +} + +# ───────────────────────────────────────────────────────────────────────────── +# 11. INTERACTIVE MENU +# ───────────────────────────────────────────────────────────────────────────── + +show_install_menu() { + clear 2>/dev/null || true + echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${BOLD}║ Loop (AID) PowerPack Installer ║${NC}" + echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" + echo + local installed_count=0 + for fid in "${ALL_FEATURE_IDS[@]}"; do + is_installed "$fid" && ((installed_count++)) || true + done + echo " Installed: ${installed_count} of ${#ALL_FEATURE_IDS[@]} features" + echo + echo -e " ${BOLD}Pick a feature to install:${NC}" + echo + local i=1 + for fid in "${ALL_FEATURE_IDS[@]}"; do + if is_installed "$fid"; then + printf " %d. %-18s ${GREEN}✓ installed${NC}\n" "$i" "${FEATURE_NAME[$fid]}" + else + printf " %d. %-18s ${DIM}— %s${NC}\n" "$i" "${FEATURE_NAME[$fid]}" "${FEATURE_DESC[$fid]}" + fi + ((i++)) + done + echo + echo -e " ${BOLD}A${NC}. Install ALL remaining features" + echo -e " ${BOLD}U${NC}. Uninstall a feature" + echo -e " ${BOLD}R${NC}. Uninstall ALL (rollback)" + echo -e " ${BOLD}Q${NC}. Quit" + echo +} + +show_uninstall_menu() { + clear 2>/dev/null || true + echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${BOLD}║ Uninstall a Feature ║${NC}" + echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" + echo + local i=1 + local installed_ids=() + for fid in "${ALL_FEATURE_IDS[@]}"; do + if is_installed "$fid"; then + printf " %d. %s\n" "$i" "${FEATURE_NAME[$fid]}" + installed_ids+=("$fid") + ((i++)) + fi + done + if [[ ${#installed_ids[@]} -eq 0 ]]; then + echo " (no features currently installed)" + echo + read -r -p " Press Enter to return to main menu..." + return + fi + echo + echo -e " ${BOLD}Q${NC}. Back to main menu" + echo + read -r -p " Choose: " choice + case "$choice" in + Q|q|"") return ;; + [1-9]) + local idx=$((choice - 1)) + if [[ $idx -ge 0 && $idx -lt ${#installed_ids[@]} ]]; then + uninstall_one "${installed_ids[$idx]}" + read -r -p " Press Enter to return to main menu..." + fi + ;; + esac +} + +interactive_loop() { + while true; do + show_install_menu + read -r -p " Choose [1-${#ALL_FEATURE_IDS[@]} / A / U / R / Q]: " choice + case "$choice" in + [1-6]) + local idx=$((choice - 1)) + local fid="${ALL_FEATURE_IDS[$idx]}" + install_one "$fid" + read -r -p " Press Enter to return to main menu..." + ;; + A|a) + for fid in "${ALL_FEATURE_IDS[@]}"; do + is_installed "$fid" || install_one "$fid" + done + read -r -p " All-features install complete. Press Enter to return..." + ;; + U|u) + show_uninstall_menu + ;; + R|r) + echo + read -r -p " Uninstall ALL installed features? [y/N]: " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + for fid in "${ALL_FEATURE_IDS[@]}"; do + is_installed "$fid" && uninstall_one "$fid" + done + rm -f "Loop/${LEGACY_MARKER}" + fi + read -r -p " Press Enter to return to main menu..." + ;; + Q|q|"") + cleanup_remote + farewell + exit 0 + ;; + *) + warn "Invalid choice: $choice" + sleep 1 + ;; + esac + done +} + +cleanup_remote() { + pushd Loop > /dev/null 2>&1 || return + if git remote | grep -q "^${FEATURE_REMOTE}$"; then + git remote remove "$FEATURE_REMOTE" 2>/dev/null || true + fi + popd > /dev/null +} + +farewell() { + echo + echo -e "${GREEN}${BOLD}════════════════════════════════════════════════════${NC}" + echo -e "${GREEN}${BOLD} Done!${NC}" + echo -e "${GREEN}${BOLD}════════════════════════════════════════════════════${NC}" + echo + echo -e " ${BOLD}Next steps:${NC}" + echo " 1. Open LoopWorkspace.xcworkspace in Xcode" + echo " 2. Build and run (Cmd+R)" + echo " 3. Each installed feature appears in Loop → Settings" + echo + echo -e " ${BOLD}To re-run this installer:${NC}" + echo " ./Scripts/install_features.sh" + echo +} + +# ───────────────────────────────────────────────────────────────────────────── +# 12. CLI FLAG HANDLERS +# ───────────────────────────────────────────────────────────────────────────── + +cli_install_all() { + for fid in "${ALL_FEATURE_IDS[@]}"; do + is_installed "$fid" || install_one "$fid" + done + cleanup_remote + farewell +} + +cli_install_feature() { + install_one "$1" + cleanup_remote + echo; success "Feature install complete." +} + +cli_uninstall_all() { + for fid in "${ALL_FEATURE_IDS[@]}"; do + is_installed "$fid" && uninstall_one "$fid" + done + rm -f "Loop/${LEGACY_MARKER}" + cleanup_remote + echo; success "Rollback complete. All PowerPack features removed." +} + +cli_uninstall_feature() { + uninstall_one "$1" + cleanup_remote + echo; success "Feature uninstall complete." +} + +# ───────────────────────────────────────────────────────────────────────────── +# 13. INIT — common to every code path +# ───────────────────────────────────────────────────────────────────────────── + +init_common() { + validate_environment + ensure_source_remote + apply_omnible_base + bump_version_once + create_loop_stash_once +} + +# ───────────────────────────────────────────────────────────────────────────── +# 14. MAIN +# ───────────────────────────────────────────────────────────────────────────── + +main() { + local mode="interactive" + local feature="" + while [[ $# -gt 0 ]]; do + case "$1" in + --all) mode="install_all"; shift ;; + --rollback) mode="uninstall_all"; shift ;; + --feature) mode="install_feature"; feature="$2"; shift 2 ;; + --uninstall) mode="uninstall_feature"; feature="$2"; shift 2 ;; + -h|--help) + sed -n '2,15p' "${BASH_SOURCE[0]:-$0}" | sed 's|^# \?||' + exit 0 + ;; + *) + die "Unknown argument: $1" + ;; + esac + done + + init_common + case "$mode" in + interactive) interactive_loop ;; + install_all) cli_install_all ;; + install_feature) cli_install_feature "$feature" ;; + uninstall_all) cli_uninstall_all ;; + uninstall_feature) cli_uninstall_feature "$feature" ;; + esac +} + +main "$@" diff --git a/Scripts/update_pbxproj.py b/Scripts/update_pbxproj.py new file mode 100755 index 0000000000..cdfb2ba59c --- /dev/null +++ b/Scripts/update_pbxproj.py @@ -0,0 +1,653 @@ +#!/usr/bin/env python3 +""" +update_pbxproj.py — Add or remove Loop (AID) PowerPack files in + Loop.xcodeproj/project.pbxproj. + +USAGE + python3 update_pbxproj.py [--features ] [--remove-features ] + + --features Comma-separated feature ids to add (default: all) + --remove-features Comma-separated feature ids to remove + + Feature ids: autopresets, bolus_pro, graph_detail_view, site_atlas, + food_finder, loop_insights + +EXAMPLES + python3 update_pbxproj.py Loop/Loop.xcodeproj/project.pbxproj + → adds every PowerPack file (back-compat: same as the old all-features run) + + python3 update_pbxproj.py --features bolus_pro Loop/Loop.xcodeproj/project.pbxproj + → adds only BolusPro files + + python3 update_pbxproj.py --remove-features bolus_pro Loop/Loop.xcodeproj/project.pbxproj + → removes only BolusPro file references / build entries + +DETERMINISTIC UUIDS + Each file/group/buildfile gets a stable md5-derived UUID so repeated + installs and remove → reinstall cycles produce identical pbxproj content. + This is what makes the per-feature removal code able to find the exact + entries it added. + +GROUP CLEANUP + Removing a feature deletes its PBXBuildFile, PBXFileReference, group + children, and PBXSourcesBuildPhase entries. Empty PBXGroup definitions + are intentionally left in place — they're harmless and reusing them on + reinstall is faster than recreating. + +Idea by Taylor Patterson. Coded by Claude Code. +Copyright © 2026 LoopKit Authors and Taylor Patterson. +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +from typing import Optional + + +# ───────────────────────────────────────────────────────────────────────────── +# Feature ids +# ───────────────────────────────────────────────────────────────────────────── + +ALL_FEATURE_IDS = ( + "autopresets", + "bolus_pro", + "graph_detail_view", + "site_atlas", + "food_finder", + "loop_insights", +) + + +def make_uuid(name: str) -> str: + """Generate a deterministic 24-char hex UUID from a name.""" + return hashlib.md5(f"FeatureInstaller_{name}".encode()).hexdigest()[:24].upper() + + +def fileref_uuid(filename: str) -> str: + return make_uuid(f"fileref_{filename}") + + +def buildfile_uuid(filename: str) -> str: + return make_uuid(f"buildfile_{filename}") + + +def group_uuid(group_key: str) -> str: + return make_uuid(f"group_{group_key}") + + +# ───────────────────────────────────────────────────────────────────────────── +# File manifest +# +# (relative_path_from_Loop/, filename, parent_group_key, feature_id) +# ───────────────────────────────────────────────────────────────────────────── + +SOURCE_FILES: list[tuple[str, str, str, str]] = [ + # ── GraphDetailView ── + ("Managers/GraphDetailViewModel.swift", "GraphDetailViewModel.swift", "Managers", "graph_detail_view"), + ("Views/GraphDetailView.swift", "GraphDetailView.swift", "Views", "graph_detail_view"), + + # ── AutoPresets — Managers ── + ("Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift", "AutoPresets_ActivityDetectionManager.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_Coordinator.swift", "AutoPresets_Coordinator.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_Delegate.swift", "AutoPresets_Delegate.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_GeofenceManager.swift", "AutoPresets_GeofenceManager.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_CalendarManager.swift", "AutoPresets_CalendarManager.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_Logger.swift", "AutoPresets_Logger.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_Storage.swift", "AutoPresets_Storage.swift", "Managers/AutoPresets", "autopresets"), + + # ── AutoPresets — Models ── + ("Models/AutoPresets/AutoPresets_Models.swift", "AutoPresets_Models.swift", "Models/AutoPresets", "autopresets"), + ("Models/AutoPresets/AutoPresets_RecommendationModels.swift", "AutoPresets_RecommendationModels.swift", "Models/AutoPresets", "autopresets"), + + # ── AutoPresets — Services ── + ("Services/AutoPresets/AutoPresets_AIAdvisor.swift", "AutoPresets_AIAdvisor.swift", "Services/AutoPresets", "autopresets"), + + # ── AutoPresets — Resources ── + ("Resources/AutoPresets/AutoPresets_FeatureFlags.swift", "AutoPresets_FeatureFlags.swift", "Resources/AutoPresets", "autopresets"), + + # ── AutoPresets — Views ── + ("Views/AutoPresets/AutoPresets_AIRecommendationView.swift", "AutoPresets_AIRecommendationView.swift", "Views/AutoPresets", "autopresets"), + ("Views/AutoPresets/AutoPresets_GeofenceSettingsView.swift", "AutoPresets_GeofenceSettingsView.swift", "Views/AutoPresets", "autopresets"), + ("Views/AutoPresets/AutoPresets_CalendarSettingsView.swift", "AutoPresets_CalendarSettingsView.swift", "Views/AutoPresets", "autopresets"), + ("Views/AutoPresets/AutoPresets_SettingsView.swift", "AutoPresets_SettingsView.swift", "Views/AutoPresets", "autopresets"), + + # ── BolusPro ── + ("Models/BolusPro/BolusPro_Models.swift", "BolusPro_Models.swift", "Models/BolusPro", "bolus_pro"), + ("Resources/BolusPro/BolusPro_FeatureFlags.swift", "BolusPro_FeatureFlags.swift", "Resources/BolusPro", "bolus_pro"), + ("Services/BolusPro/BolusPro_FPUCalculator.swift", "BolusPro_FPUCalculator.swift", "Services/BolusPro", "bolus_pro"), + ("Services/BolusPro/BolusPro_DataLayerHook.swift", "BolusPro_DataLayerHook.swift", "Services/BolusPro", "bolus_pro"), + ("Services/BolusPro/BolusPro_BehaviorAnalyzer.swift", "BolusPro_BehaviorAnalyzer.swift", "Services/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_InfoSheet.swift", "BolusPro_InfoSheet.swift", "Views/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_OnboardingView.swift", "BolusPro_OnboardingView.swift", "Views/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_ManualMacroFields.swift", "BolusPro_ManualMacroFields.swift", "Views/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_CarbEntrySection.swift", "BolusPro_CarbEntrySection.swift", "Views/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_SettingsView.swift", "BolusPro_SettingsView.swift", "Views/BolusPro", "bolus_pro"), + + # ── FoodFinder ── + ("Models/FoodFinder/FoodFinder_AnalysisRecord.swift", "FoodFinder_AnalysisRecord.swift", "Models/FoodFinder", "food_finder"), + ("Models/FoodFinder/FoodFinder_InputResults.swift", "FoodFinder_InputResults.swift", "Models/FoodFinder", "food_finder"), + ("Models/FoodFinder/FoodFinder_Models.swift", "FoodFinder_Models.swift", "Models/FoodFinder", "food_finder"), + ("Resources/FoodFinder/FoodFinder_FeatureFlags.swift", "FoodFinder_FeatureFlags.swift", "Resources/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AIAnalysis.swift", "FoodFinder_AIAnalysis.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AIProviderConfig.swift","FoodFinder_AIProviderConfig.swift","Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AIServiceAdapter.swift","FoodFinder_AIServiceAdapter.swift","Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AIServiceManager.swift","FoodFinder_AIServiceManager.swift","Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AnalysisHistoryStore.swift","FoodFinder_AnalysisHistoryStore.swift","Services/FoodFinder","food_finder"), + ("Services/FoodFinder/FoodFinder_CarbTrackingService.swift","FoodFinder_CarbTrackingService.swift","Services/FoodFinder","food_finder"), + ("Services/FoodFinder/FoodFinder_EmojiProvider.swift", "FoodFinder_EmojiProvider.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_ImageDownloader.swift","FoodFinder_ImageDownloader.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_ImageStore.swift", "FoodFinder_ImageStore.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_LocationService.swift","FoodFinder_LocationService.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_OpenFoodFactsService.swift","FoodFinder_OpenFoodFactsService.swift","Services/FoodFinder","food_finder"), + ("Services/FoodFinder/FoodFinder_ScannerService.swift", "FoodFinder_ScannerService.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_SearchRouter.swift", "FoodFinder_SearchRouter.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_SecureStorage.swift", "FoodFinder_SecureStorage.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_VoiceService.swift", "FoodFinder_VoiceService.swift", "Services/FoodFinder", "food_finder"), + ("View Models/FoodFinder/FoodFinder_SearchViewModel.swift","FoodFinder_SearchViewModel.swift","View Models/FoodFinder","food_finder"), + ("Views/FoodFinder/FoodFinder_AICameraView.swift", "FoodFinder_AICameraView.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_CarbTrackingDashboard.swift","FoodFinder_CarbTrackingDashboard.swift","Views/FoodFinder","food_finder"), + ("Views/FoodFinder/FoodFinder_EntryPoint.swift", "FoodFinder_EntryPoint.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_FavoritesHelpers.swift", "FoodFinder_FavoritesHelpers.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_ImageCropView.swift", "FoodFinder_ImageCropView.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_ScannerView.swift", "FoodFinder_ScannerView.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_SearchBar.swift", "FoodFinder_SearchBar.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_SearchResultsView.swift", "FoodFinder_SearchResultsView.swift","Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_SettingsView.swift", "FoodFinder_SettingsView.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_VoiceSearchView.swift", "FoodFinder_VoiceSearchView.swift", "Views/FoodFinder", "food_finder"), + + # ── LoopInsights (includes DataLayer infrastructure) ── + ("Managers/LoopInsights/LoopInsights_BackgroundMonitor.swift", "LoopInsights_BackgroundMonitor.swift", "Managers/LoopInsights", "loop_insights"), + ("Managers/LoopInsights/LoopInsights_Coordinator.swift", "LoopInsights_Coordinator.swift", "Managers/LoopInsights", "loop_insights"), + ("Managers/DataLayer/DataLayer_Coordinator.swift", "DataLayer_Coordinator.swift", "Managers/DataLayer", "loop_insights"), + ("Models/LoopInsights/LoopInsights_Models.swift", "LoopInsights_Models.swift", "Models/LoopInsights", "loop_insights"), + ("Models/LoopInsights/LoopInsights_MFPModels.swift", "LoopInsights_MFPModels.swift", "Models/LoopInsights", "loop_insights"), + ("Models/LoopInsights/LoopInsights_Phase5Models.swift", "LoopInsights_Phase5Models.swift", "Models/LoopInsights", "loop_insights"), + ("Models/LoopInsights/LoopInsights_SuggestionRecord.swift", "LoopInsights_SuggestionRecord.swift", "Models/LoopInsights", "loop_insights"), + ("Models/LoopInsights/LoopInsights_MealDebriefModels.swift", "LoopInsights_MealDebriefModels.swift", "Models/LoopInsights", "loop_insights"), + ("Models/DataLayer/DataLayer_EventModels.swift", "DataLayer_EventModels.swift", "Models/DataLayer", "loop_insights"), + ("Models/DataLayer/DataLayer_ConsentModels.swift", "DataLayer_ConsentModels.swift", "Models/DataLayer", "loop_insights"), + ("Resources/LoopInsights/LoopInsights_FeatureFlags.swift", "LoopInsights_FeatureFlags.swift", "Resources/LoopInsights","loop_insights"), + ("Resources/DataLayer/DataLayer_FeatureFlags.swift", "DataLayer_FeatureFlags.swift", "Resources/DataLayer", "loop_insights"), + ("Services/LoopInsights/LoopInsights_AdvancedAnalyzers.swift", "LoopInsights_AdvancedAnalyzers.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_AIAnalysis.swift", "LoopInsights_AIAnalysis.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_AIServiceAdapter.swift", "LoopInsights_AIServiceAdapter.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_AlcoholTracker.swift", "LoopInsights_AlcoholTracker.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_BackfillDetector.swift", "LoopInsights_BackfillDetector.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_BehaviorInsightsAnalyzer.swift","LoopInsights_BehaviorInsightsAnalyzer.swift","Services/LoopInsights","loop_insights"), + ("Services/LoopInsights/LoopInsights_CaffeineTracker.swift", "LoopInsights_CaffeineTracker.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_CaregiverDigestService.swift","LoopInsights_CaregiverDigestService.swift","Services/LoopInsights","loop_insights"), + ("Services/LoopInsights/LoopInsights_ChatHistoryStore.swift", "LoopInsights_ChatHistoryStore.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_DataAggregator.swift", "LoopInsights_DataAggregator.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_FoodResponseAnalyzer.swift","LoopInsights_FoodResponseAnalyzer.swift","Services/LoopInsights","loop_insights"), + ("Services/LoopInsights/LoopInsights_GlucoseUnitContext.swift","LoopInsights_GlucoseUnitContext.swift","Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_GoalStore.swift", "LoopInsights_GoalStore.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_HealthKitManager.swift", "LoopInsights_HealthKitManager.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_NightscoutImporter.swift","LoopInsights_NightscoutImporter.swift","Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_ReportGenerator.swift", "LoopInsights_ReportGenerator.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_SecureStorage.swift", "LoopInsights_SecureStorage.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_SuggestionStore.swift", "LoopInsights_SuggestionStore.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_TestDataProvider.swift", "LoopInsights_TestDataProvider.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_VoiceService.swift", "LoopInsights_VoiceService.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_MealDebriefService.swift","LoopInsights_MealDebriefService.swift","Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_MFPImporter.swift", "LoopInsights_MFPImporter.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_PreMealAdvisorService.swift","LoopInsights_PreMealAdvisorService.swift","Services/LoopInsights","loop_insights"), + ("Services/DataLayer/DataLayer_SecureStorage.swift", "DataLayer_SecureStorage.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_ConsentManager.swift", "DataLayer_ConsentManager.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_EventStore.swift", "DataLayer_EventStore.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_EventCollector.swift", "DataLayer_EventCollector.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_SyncService.swift", "DataLayer_SyncService.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_ReportGenerator.swift", "DataLayer_ReportGenerator.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_ProviderProtocol.swift", "DataLayer_ProviderProtocol.swift", "Services/DataLayer", "loop_insights"), + ("View Models/LoopInsights/LoopInsights_ChatViewModel.swift", "LoopInsights_ChatViewModel.swift", "View Models/LoopInsights","loop_insights"), + ("View Models/LoopInsights/LoopInsights_DashboardViewModel.swift","LoopInsights_DashboardViewModel.swift","View Models/LoopInsights","loop_insights"), + ("View Models/LoopInsights/LoopInsights_MealInsightsViewModel.swift","LoopInsights_MealInsightsViewModel.swift","View Models/LoopInsights","loop_insights"), + ("Views/LoopInsights/LoopInsights_AGPChartView.swift", "LoopInsights_AGPChartView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_AlcoholLogView.swift", "LoopInsights_AlcoholLogView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_BehaviorInsightsView.swift", "LoopInsights_BehaviorInsightsView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_CaregiverDigestView.swift", "LoopInsights_CaregiverDigestView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_EndoReportView.swift", "LoopInsights_EndoReportView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_ChatHistoryView.swift", "LoopInsights_ChatHistoryView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_CaffeineLogView.swift", "LoopInsights_CaffeineLogView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_ChatView.swift", "LoopInsights_ChatView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_DashboardView.swift", "LoopInsights_DashboardView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_GoalsView.swift", "LoopInsights_GoalsView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_MealInsightsView.swift", "LoopInsights_MealInsightsView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_MonitorSettingsView.swift", "LoopInsights_MonitorSettingsView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_SettingsView.swift", "LoopInsights_SettingsView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_SuggestionDetailView.swift", "LoopInsights_SuggestionDetailView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_SuggestionHistoryView.swift","LoopInsights_SuggestionHistoryView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_TrendsInsightsView.swift", "LoopInsights_TrendsInsightsView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_MealDebriefCard.swift", "LoopInsights_MealDebriefCard.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_PreMealAdvisorCard.swift", "LoopInsights_PreMealAdvisorCard.swift","Views/LoopInsights", "loop_insights"), + ("Views/DataLayer/DataLayer_ConsentView.swift", "DataLayer_ConsentView.swift", "Views/DataLayer", "loop_insights"), + ("Views/DataLayer/DataLayer_DashboardView.swift", "DataLayer_DashboardView.swift", "Views/DataLayer", "loop_insights"), + + # ── SiteAtlas ── + ("Models/SiteAtlas/SiteAtlas_Models.swift", "SiteAtlas_Models.swift", "Models/SiteAtlas", "site_atlas"), + ("Services/SiteAtlas/SiteAtlas_Coordinator.swift", "SiteAtlas_Coordinator.swift", "Services/SiteAtlas","site_atlas"), + ("Services/SiteAtlas/SiteAtlas_FeatureFlags.swift", "SiteAtlas_FeatureFlags.swift", "Services/SiteAtlas","site_atlas"), + ("Services/SiteAtlas/SiteAtlas_Storage.swift", "SiteAtlas_Storage.swift", "Services/SiteAtlas","site_atlas"), + ("Views/SiteAtlas/SiteAtlas_BodyMapView.swift", "SiteAtlas_BodyMapView.swift", "Views/SiteAtlas", "site_atlas"), + ("Views/SiteAtlas/SiteAtlas_SettingsView.swift", "SiteAtlas_SettingsView.swift", "Views/SiteAtlas", "site_atlas"), + ("Views/SiteAtlas/SiteAtlas_SiteSelectionSheet.swift", "SiteAtlas_SiteSelectionSheet.swift", "Views/SiteAtlas", "site_atlas"), +] + +TEST_FILES: list[tuple[str, str, str, str]] = [ + ("FoodFinder/FoodFinder_BarcodeScannerTests.swift", "FoodFinder_BarcodeScannerTests.swift", "LoopTests/FoodFinder", "food_finder"), + ("FoodFinder/FoodFinder_OpenFoodFactsTests.swift", "FoodFinder_OpenFoodFactsTests.swift", "LoopTests/FoodFinder", "food_finder"), + ("FoodFinder/FoodFinder_VoiceSearchTests.swift", "FoodFinder_VoiceSearchTests.swift", "LoopTests/FoodFinder", "food_finder"), + ("LoopInsights/LoopInsights_DataAggregatorTests.swift", "LoopInsights_DataAggregatorTests.swift", "LoopTests/LoopInsights", "loop_insights"), + ("LoopInsights/LoopInsights_ModelsTests.swift", "LoopInsights_ModelsTests.swift", "LoopTests/LoopInsights", "loop_insights"), + ("LoopInsights/LoopInsights_SuggestionStoreTests.swift", "LoopInsights_SuggestionStoreTests.swift", "LoopTests/LoopInsights", "loop_insights"), +] + +# (group_key, display_name, path, parent_group_key, owning_feature_or_None) +# owning_feature is set for feature-specific subgroups; shared parent groups +# (Services, Resources) have None. None-owned groups are never removed on +# uninstall — they persist for any other feature's use. + +SUBGROUPS: list[tuple[str, str, str, str, Optional[str]]] = [ + # Generic top-level groups under Loop (created on first use, never removed) + ("Services", "Services", "Services", "Loop", None), + ("Resources", "Resources", "Resources", "Loop", None), + + # AutoPresets feature subgroups + ("Managers/AutoPresets", "AutoPresets", "AutoPresets", "Managers", "autopresets"), + ("Models/AutoPresets", "AutoPresets", "AutoPresets", "Models", "autopresets"), + ("Services/AutoPresets", "AutoPresets", "AutoPresets", "Services", "autopresets"), + ("Resources/AutoPresets", "AutoPresets", "AutoPresets", "Resources", "autopresets"), + ("Views/AutoPresets", "AutoPresets", "AutoPresets", "Views", "autopresets"), + + # BolusPro feature subgroups + ("Models/BolusPro", "BolusPro", "BolusPro", "Models", "bolus_pro"), + ("Resources/BolusPro", "BolusPro", "BolusPro", "Resources", "bolus_pro"), + ("Services/BolusPro", "BolusPro", "BolusPro", "Services", "bolus_pro"), + ("Views/BolusPro", "BolusPro", "BolusPro", "Views", "bolus_pro"), + + # FoodFinder feature subgroups + ("Models/FoodFinder", "FoodFinder", "FoodFinder", "Models", "food_finder"), + ("Resources/FoodFinder", "FoodFinder", "FoodFinder", "Resources", "food_finder"), + ("Services/FoodFinder", "FoodFinder", "FoodFinder", "Services", "food_finder"), + ("View Models/FoodFinder", "FoodFinder", "FoodFinder", "View Models","food_finder"), + ("Views/FoodFinder", "FoodFinder", "FoodFinder", "Views", "food_finder"), + ("LoopTests/FoodFinder", "FoodFinder", "FoodFinder", "LoopTests", "food_finder"), + + # LoopInsights feature subgroups (incl. DataLayer) + ("Managers/LoopInsights", "LoopInsights", "LoopInsights", "Managers", "loop_insights"), + ("Managers/DataLayer", "DataLayer", "DataLayer", "Managers", "loop_insights"), + ("Models/LoopInsights", "LoopInsights", "LoopInsights", "Models", "loop_insights"), + ("Models/DataLayer", "DataLayer", "DataLayer", "Models", "loop_insights"), + ("Resources/LoopInsights", "LoopInsights", "LoopInsights", "Resources", "loop_insights"), + ("Resources/DataLayer", "DataLayer", "DataLayer", "Resources", "loop_insights"), + ("Services/LoopInsights", "LoopInsights", "LoopInsights", "Services", "loop_insights"), + ("Services/DataLayer", "DataLayer", "DataLayer", "Services", "loop_insights"), + ("View Models/LoopInsights","LoopInsights", "LoopInsights", "View Models","loop_insights"), + ("Views/LoopInsights", "LoopInsights", "LoopInsights", "Views", "loop_insights"), + ("Views/DataLayer", "DataLayer", "DataLayer", "Views", "loop_insights"), + ("LoopTests/LoopInsights", "LoopInsights", "LoopInsights", "LoopTests", "loop_insights"), + + # SiteAtlas feature subgroups + ("Models/SiteAtlas", "SiteAtlas", "SiteAtlas", "Models", "site_atlas"), + ("Services/SiteAtlas", "SiteAtlas", "SiteAtlas", "Services", "site_atlas"), + ("Views/SiteAtlas", "SiteAtlas", "SiteAtlas", "Views", "site_atlas"), +] + + +# ───────────────────────────────────────────────────────────────────────────── +# pbxproj parsing +# ───────────────────────────────────────────────────────────────────────────── + +def parse_all_groups(content: str) -> dict[str, dict]: + """Parse all PBXGroup definitions into a dict of uuid -> {name, path, children_uuids}.""" + section_match = re.search( + r'/\* Begin PBXGroup section \*/\n(.*?)\n/\* End PBXGroup section \*/', + content, re.DOTALL, + ) + if not section_match: + return {} + section = section_match.group(1) + groups: dict[str, dict] = {} + for m in re.finditer( + r'^\t\t([A-F0-9]{24})\s*(?:/\*[^\n]*?\*/)?\s*= \{\n(.*?)\n\t\t\};', + section, re.MULTILINE | re.DOTALL, + ): + uuid = m.group(1) + body = m.group(2) + if "isa = PBXGroup" not in body: + continue + path_m = re.search(r'path = "(.*?)";|path = ([^;"\s]+);', body) + name_m = re.search(r'name = "(.*?)";|name = ([^;"\s]+);', body) + path_val = (path_m.group(1) or path_m.group(2)) if path_m else None + name_val = (name_m.group(1) or name_m.group(2)) if name_m else None + display = name_val or path_val or "unknown" + children = [] + children_m = re.search(r'children = \(\n(.*?)\n\t\t\t\);', body, re.DOTALL) + if children_m: + for c in re.finditer(r'([A-F0-9]{24})', children_m.group(1)): + children.append(c.group(1)) + groups[uuid] = {"name": display, "path": path_val, "children": children} + return groups + + +def find_groups_by_hierarchy(content: str) -> dict[str, str]: + """Walk PBXProject → mainGroup → Loop. Returns {logical_name: uuid}.""" + all_groups = parse_all_groups(content) + main_group_match = re.search(r'mainGroup = ([A-F0-9]{24})', content) + if not main_group_match: + return {} + main_group_uuid = main_group_match.group(1) + main_group = all_groups.get(main_group_uuid, {}) + result: dict[str, str] = {} + for child_uuid in main_group.get("children", []): + child = all_groups.get(child_uuid, {}) + child_path = child.get("path") + child_name = child.get("name") + if child_path == "Loop" or child_name == "Loop": + result["Loop"] = child_uuid + elif child_path == "LoopTests" or child_name == "LoopTests": + result["LoopTests"] = child_uuid + loop_uuid = result.get("Loop") + if loop_uuid and loop_uuid in all_groups: + for child_uuid in all_groups[loop_uuid]["children"]: + child = all_groups.get(child_uuid, {}) + display = child.get("name") or child.get("path") + if display in ("Views", "Models", "View Models", "Managers", "Services", "Resources"): + result[display] = child_uuid + return result + + +def find_main_sources_phase(content: str) -> Optional[str]: + target_section = re.search( + r'/\* Begin PBXNativeTarget section \*/\n(.*?)\n/\* End PBXNativeTarget section \*/', + content, re.DOTALL, + ) + if not target_section: + return None + for m in re.finditer( + r'([A-F0-9]{24}) /\* (Loop[^*]*?)\*/ = \{(.*?)\n\t\t\};', + target_section.group(1), re.DOTALL, + ): + target_name = m.group(2).strip() + if target_name == "Loop": + phases_match = re.search(r'buildPhases = \(\n(.*?)\n\t\t\t\);', m.group(3), re.DOTALL) + if phases_match: + sources_match = re.search(r'([A-F0-9]{24}) /\*[^\n]*?Sources[^\n]*?\*/', phases_match.group(1)) + if sources_match: + return sources_match.group(1) + return None + + +def find_test_sources_phase(content: str) -> Optional[str]: + target_section = re.search( + r'/\* Begin PBXNativeTarget section \*/\n(.*?)\n/\* End PBXNativeTarget section \*/', + content, re.DOTALL, + ) + if not target_section: + return None + for m in re.finditer( + r'([A-F0-9]{24}) /\* (LoopTests[^*]*?)\*/ = \{(.*?)\n\t\t\};', + target_section.group(1), re.DOTALL, + ): + target_name = m.group(2).strip() + if target_name == "LoopTests": + phases_match = re.search(r'buildPhases = \(\n(.*?)\n\t\t\t\);', m.group(3), re.DOTALL) + if phases_match: + sources_match = re.search(r'([A-F0-9]{24}) /\*[^\n]*?Sources[^\n]*?\*/', phases_match.group(1)) + if sources_match: + return sources_match.group(1) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# pbxproj mutation: ADD +# ───────────────────────────────────────────────────────────────────────────── + +def add_child_to_group(content: str, parent_uuid: str, child_uuid: str, child_name: str) -> str: + new_child = f"\t\t\t\t{child_uuid} /* {child_name} */," + pattern = ( + f"({parent_uuid} /\\*[^\\n]*?\\*/ = \\{{\n" + f"\\t\\t\\tisa = PBXGroup;\n" + f"\\t\\t\\tchildren = \\(\n)" + f"(.*?)" + f"(\\n\\t\\t\\t\\);)" + ) + match = re.search(pattern, content, re.DOTALL) + if match: + if child_uuid in match.group(2): + return content # already present + content = content[:match.start()] + f"{match.group(1)}{match.group(2)}\n{new_child}{match.group(3)}" + content[match.end():] + return content + + +def add_to_build_phase(content: str, phase_uuid: str, entries_block: str) -> str: + pattern = ( + f"({phase_uuid} /\\*[^\\n]*?\\*/ = \\{{\n" + f"\\t\\t\\tisa = PBXSourcesBuildPhase;\n" + f"\\t\\t\\tbuildActionMask = \\d+;\n" + f"\\t\\t\\tfiles = \\(\n)" + f"(.*?)" + f"(\\n\\t\\t\\t\\);\\n\\t\\t\\trunOnlyForDeploymentPostprocessing)" + ) + match = re.search(pattern, content, re.DOTALL) + if match: + content = content[:match.start()] + f"{match.group(1)}{match.group(2)}\n{entries_block}{match.group(3)}" + content[match.end():] + return content + + +def build_group_def(uuid: str, name: str, path: str, child_entries: list[tuple[str, str]]) -> str: + children_lines = [f"\t\t\t\t{cuuid} /* {cname} */," for cuuid, cname in child_entries] + return ( + f"\t\t{uuid} /* {name} */ = {{\n" + f"\t\t\tisa = PBXGroup;\n" + f"\t\t\tchildren = (\n" + f"{chr(10).join(children_lines)}\n" + f"\t\t\t);\n" + f"\t\t\tpath = {path};\n" + f"\t\t\tsourceTree = \"\";\n" + f"\t\t}};" + ) + + +def add_features(content: str, feature_ids: set[str]) -> str: + print(f" Adding features: {sorted(feature_ids)}") + known = find_groups_by_hierarchy(content) + main_sources = find_main_sources_phase(content) + test_sources = find_test_sources_phase(content) + + # Filter manifests to selected features. + src = [t for t in SOURCE_FILES if t[3] in feature_ids] + tst = [t for t in TEST_FILES if t[3] in feature_ids] + # SUBGROUPS we need: shared parents (None-owned) plus this feature's subgroups. + sub = [t for t in SUBGROUPS if t[4] is None or t[4] in feature_ids] + + # ── 1. PBXBuildFile entries + build_entries = [] + for path, name, _, _ in src + tst: + bf = buildfile_uuid(name) + fr = fileref_uuid(name) + entry = f"\t\t{bf} /* {name} in Sources */ = {{isa = PBXBuildFile; fileRef = {fr} /* {name} */; }};" + if bf not in content: + build_entries.append(entry) + if build_entries: + content = content.replace( + "/* End PBXBuildFile section */", + "\n".join(build_entries) + "\n/* End PBXBuildFile section */", + ) + + # ── 2. PBXFileReference entries + ref_entries = [] + for path, name, _, _ in src + tst: + fr = fileref_uuid(name) + entry = ( + f"\t\t{fr} /* {name} */ = " + f"{{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; " + f"path = {name}; sourceTree = \"\"; }};" + ) + if fr not in content: + ref_entries.append(entry) + if ref_entries: + content = content.replace( + "/* End PBXFileReference section */", + "\n".join(ref_entries) + "\n/* End PBXFileReference section */", + ) + + # ── 3. Build child lists per subgroup + group_children: dict[str, list[tuple[str, str]]] = {gkey: [] for gkey, _, _, _, _ in sub} + for path, name, gkey, _ in src + tst: + group_children.setdefault(gkey, []).append((fileref_uuid(name), name)) + for gkey, gname, _, gparent, _ in sub: + group_children.setdefault(gparent, []).append((group_uuid(gkey), gname)) + + # ── 4. Create PBXGroup defs for new subgroups + new_group_defs = [] + for gkey, gname, gpath, gparent, _ in sub: + if gkey in known: + continue + gu = group_uuid(gkey) + # Don't recreate if the group def already exists from a prior install + if f"{gu} /* {gname} */ = " in content: + continue + children = group_children.get(gkey, []) + new_group_defs.append(build_group_def(gu, gname, gpath, children)) + if new_group_defs: + content = content.replace( + "/* End PBXGroup section */", + "\n".join(new_group_defs) + "\n/* End PBXGroup section */", + ) + + # ── 5. Link new subgroups into existing parents + group_uuids = {gkey: known.get(gkey, group_uuid(gkey)) for gkey, _, _, _, _ in sub} + for name, uuid in known.items(): + group_uuids.setdefault(name, uuid) + for gkey, gname, _, gparent, _ in sub: + parent_uuid = group_uuids.get(gparent) + child_uuid = group_uuids[gkey] + if parent_uuid is None or gparent not in known: + continue + content = add_child_to_group(content, parent_uuid, child_uuid, gname) + + # ── 5b. Add files directly to existing parent groups (e.g. GraphDetailViewModel under Managers) + subgroup_keys = {gkey for gkey, _, _, _, _ in sub} + for path, name, gkey, _ in src: + if gkey not in subgroup_keys and gkey in known: + content = add_child_to_group(content, known[gkey], fileref_uuid(name), name) + + # ── 6. Add files to PBXSourcesBuildPhase + if main_sources and src: + main_entries = [] + for _, name, _, _ in src: + bf = buildfile_uuid(name) + line = f"\t\t\t\t{bf} /* {name} in Sources */," + if line not in content: + main_entries.append(line) + if main_entries: + content = add_to_build_phase(content, main_sources, "\n".join(main_entries)) + + if test_sources and tst: + test_entries = [] + for _, name, _, _ in tst: + bf = buildfile_uuid(name) + line = f"\t\t\t\t{bf} /* {name} in Sources */," + if line not in content: + test_entries.append(line) + if test_entries: + content = add_to_build_phase(content, test_sources, "\n".join(test_entries)) + + print(f" Added {len(src)} source files, {len(tst)} test files, {len(new_group_defs)} new groups") + return content + + +# ───────────────────────────────────────────────────────────────────────────── +# pbxproj mutation: REMOVE +# ───────────────────────────────────────────────────────────────────────────── + +def remove_uuid_lines(content: str, uuid: str) -> str: + """Remove every line that contains the given UUID. Used to scrub: + - PBXBuildFile entries (whole one-line definition) + - PBXFileReference entries (whole one-line definition) + - Children references in PBXGroup + - File entries in PBXSourcesBuildPhase + """ + new_lines = [] + for line in content.split("\n"): + if uuid in line: + continue + new_lines.append(line) + return "\n".join(new_lines) + + +def remove_features(content: str, feature_ids: set[str]) -> str: + print(f" Removing features: {sorted(feature_ids)}") + src = [t for t in SOURCE_FILES if t[3] in feature_ids] + tst = [t for t in TEST_FILES if t[3] in feature_ids] + + removed_count = 0 + for _, name, _, _ in src + tst: + bf = buildfile_uuid(name) + fr = fileref_uuid(name) + before = content + content = remove_uuid_lines(content, bf) + content = remove_uuid_lines(content, fr) + if content != before: + removed_count += 1 + + print(f" Scrubbed {removed_count} file refs across PBXBuildFile / PBXFileReference / PBXGroup / PBXSourcesBuildPhase") + return content + + +# ───────────────────────────────────────────────────────────────────────────── +# main +# ───────────────────────────────────────────────────────────────────────────── + +def parse_features_arg(s: Optional[str]) -> set[str]: + if not s: + return set() + out: set[str] = set() + for raw in s.split(","): + v = raw.strip().lower() + if not v: + continue + if v not in ALL_FEATURE_IDS: + print(f" WARNING: Unknown feature id: {v}", file=sys.stderr) + continue + out.add(v) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=True, description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--features", default=None, + help="Comma-separated feature ids to add (default: all when no remove flag is set)") + parser.add_argument("--remove-features", default=None, + help="Comma-separated feature ids to remove") + parser.add_argument("pbxproj", help="Path to project.pbxproj") + args = parser.parse_args() + + add_set = parse_features_arg(args.features) + rem_set = parse_features_arg(args.remove_features) + + # Default: add every feature (back-compat with the old monolithic invocation). + if not add_set and not rem_set: + add_set = set(ALL_FEATURE_IDS) + + with open(args.pbxproj, "r") as f: + content = f.read() + + if rem_set: + content = remove_features(content, rem_set) + if add_set: + content = add_features(content, add_set) + + with open(args.pbxproj, "w") as f: + f.write(content) + + print(f"\n Wrote {args.pbxproj}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/VersionOverride.xcconfig b/VersionOverride.xcconfig index 4e7b318af7..8628cebe14 100644 --- a/VersionOverride.xcconfig +++ b/VersionOverride.xcconfig @@ -8,5 +8,5 @@ // Version [for DIY Loop] // configure the version number in LoopWorkspace -LOOP_MARKETING_VERSION = 3.10.0 -CURRENT_PROJECT_VERSION = 57 +LOOP_MARKETING_VERSION = 3.13.1 +CURRENT_PROJECT_VERSION = 58