diff --git a/.gitmodules b/.gitmodules index 1308e60c03..2d19d78801 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 @@ -45,7 +46,7 @@ url = https://github.com/LoopKit/G7SensorKit.git [submodule "TidepoolService"] path = TidepoolService - url = https://github.com/LoopKit/TidepoolService.git + url = https://github.com/taylorpatterson-T1D/TidepoolService.git [submodule "OmniKit"] path = OmniKit url = https://github.com/LoopKit/OmniKit.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 5c7dd11738..f96d6cc67d 160000 --- a/Loop +++ b/Loop @@ -1 +1 @@ -Subproject commit 5c7dd117389067db61a148a732332983f2179015 +Subproject commit f96d6cc67d79230ce86289df5916f27b25e8f93f 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/LoopKit b/LoopKit index e7e2ee2b54..3c9f03b50e 160000 --- a/LoopKit +++ b/LoopKit @@ -1 +1 @@ -Subproject commit e7e2ee2b546c4d8122014838cb98a0e26dd91208 +Subproject commit 3c9f03b50e592c13356d817a7dc746b1da3a4505 diff --git a/LoopWorkspace.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoopWorkspace.xcworkspace/xcshareddata/swiftpm/Package.resolved index 85b387d04a..5ef3fcc179 100644 --- a/LoopWorkspace.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LoopWorkspace.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -168,7 +168,7 @@ "location" : "https://github.com/LoopKit/ZIPFoundation.git", "state" : { "branch" : "stream-entry", - "revision" : "c67b7509ec82ee2b4b0ab3f97742b94ed9692494" + "revision" : "ad465ee2545392153a64c0976d6e59227d0c1c70" } } ], diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/Icon.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/Icon.png index 9a01fc7a8a..ca1c027cee 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/Icon.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/Icon.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt.png index 9e63277b69..4fa50ea776 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@2x-1.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@2x-1.png index 46b16ff10e..6adc1d74ec 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@2x-1.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@2x-1.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@2x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@2x.png index 46b16ff10e..6adc1d74ec 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@2x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@2x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@3x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@3x.png index e802e8ac2e..b9aabea110 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@3x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_20pt@3x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt.png index 65516e2b2c..874ce6dc63 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@2x-1.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@2x-1.png index d47b20b371..736d90e44d 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@2x-1.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@2x-1.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@2x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@2x.png index d47b20b371..736d90e44d 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@2x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@2x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@3x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@3x.png index 05b0a95ad4..a95478179e 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@3x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_29pt@3x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt.png index 46b16ff10e..6adc1d74ec 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@2x-1.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@2x-1.png index 3ea02f17f5..891509b3f3 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@2x-1.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@2x-1.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@2x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@2x.png index 3ea02f17f5..891509b3f3 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@2x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@2x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@3x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@3x.png index 68f41228f2..52949a7015 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@3x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_40pt@3x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_60pt@2x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_60pt@2x.png index 68f41228f2..52949a7015 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_60pt@2x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_60pt@2x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_60pt@3x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_60pt@3x.png index ad599ec434..eeae040fcd 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_60pt@3x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_60pt@3x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_76pt.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_76pt.png index 0826a168d2..af7cc4ecfd 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_76pt.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_76pt.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_76pt@2x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_76pt@2x.png index 5bab5b21cd..7af14f1877 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_76pt@2x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_76pt@2x.png differ diff --git a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_83.5@2x.png b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_83.5@2x.png index b89cd4ed57..8b966d0032 100644 Binary files a/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_83.5@2x.png and b/OverrideAssetsLoop.xcassets/AppIcon.appiconset/icon_83.5@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/Icon.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/Icon.png index 9a01fc7a8a..ca1c027cee 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/Icon.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/Icon.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_108pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_108pt@2x.png index 660a9fa3d3..38a87758f7 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_108pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_108pt@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_24pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_24pt@2x.png index 8a2b5202bb..7308c45c33 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_24pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_24pt@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_27.5pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_27.5pt@2x.png index 57fe85f89f..531d350207 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_27.5pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_27.5pt@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_29pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_29pt@2x.png index 64f465126b..736d90e44d 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_29pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_29pt@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_29pt@3x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_29pt@3x.png index c0e1042519..a95478179e 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_29pt@3x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_29pt@3x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_40pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_40pt@2x.png index 42461280e5..891509b3f3 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_40pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_40pt@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_44pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_44pt@2x.png index 242f88c577..2722fe9954 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_44pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_44pt@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_50pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_50pt@2x.png index 428a927df3..7824fcd988 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_50pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_50pt@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_86pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_86pt@2x.png index 3d71febd9e..74562ce8c0 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_86pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_86pt@2x.png differ diff --git a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_98pt@2x.png b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_98pt@2x.png index ad82e87ff7..c9d4898c6a 100644 Binary files a/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_98pt@2x.png and b/OverrideAssetsWatchApp.xcassets/AppIcon.appiconset/icon_98pt@2x.png differ 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..a7a71dd5ef --- /dev/null +++ b/Scripts/install_features.sh @@ -0,0 +1,2010 @@ +#!/usr/bin/env bash +# install_features.sh — Loop (AID) PowerPack installer (all-or-nothing bundle) +# +# FULL FLOW +# Loop core + (optional) L&L customizations + PowerPack bundle + build. +# Run with no args, anywhere, and the installer walks you through it. +# +# WHAT GETS INSTALLED +# Every PowerPack feature, in one shot. Individual features can't be +# installed in isolation because they share compile-time symbols. After +# install, each feature defaults to OFF — turn the ones you want on in +# Loop → Settings. +# +# Bundle contents: +# AutoPresets, BolusPro, FoodFinder, LoopInsights, DataLayer, +# GraphDetailView, SiteAtlas. +# +# USAGE +# ./Scripts/install_features.sh interactive (default) +# ./Scripts/install_features.sh --install install non-interactively +# ./Scripts/install_features.sh --rollback uninstall non-interactively +# +# ONE-LINER (anywhere — installer detects whether you're in a LoopWorkspace): +# /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 + +# ─── Constants ──────────────────────────────────────────────────────────────── + +FEATURE_REMOTE="_feature_src" +FEATURE_BRANCH="feat/installer" +FEATURE_LOOP_BRANCH="feat/AllFeatures" +FEATURE_REPO="https://github.com/LoopPowerPack/Loop.git" +FEATURE_WORKSPACE_REPO="https://raw.githubusercontent.com/LoopPowerPack/LoopWorkspace/${FEATURE_BRANCH}" +MARKER_FILE=".feature_install_marker" + +# NOTE: PowerPack intentionally does NOT stamp a Loop marketing/build version. +# It leaves whatever the user's cloned upstream Loop carries, so the version in +# Settings always reflects their actual base. (Pinning a hard-coded value here +# previously DOWNGRADED users on newer upstream, e.g. 3.14.2 shown as 3.14.0.) +# PowerPack's own version is surfaced separately via PowerPack_BuildInfo. + +# PowerPack-specific semver, distinct from Loop's version. Surfaced in the +# in-app footer (LoopInsights dashboard + FoodFinder Settings) so users can +# tell support which release of PowerPack they're running. Bump on +# meaningful releases (new feature shipping, major bug fix, etc.) and tag +# the corresponding commit on LoopPowerPack/Loop with the same value +# (e.g., `git tag powerpack-0.2.0`) so version-to-commit lookups are easy. +POWERPACK_VERSION="0.3.5" + +# Colors +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' # No Color + +# ─── New files (don't exist in standard Loop) ──────────────────────────────── + +NEW_FILES=( + # Documentation + "Documentation/FoodFinder/FoodFinder_README.md" + "Documentation/LoopInsights/LoopInsights_README.md" + + # AutoPresets — Managers + "Loop/Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift" + "Loop/Managers/AutoPresets/AutoPresets_Coordinator.swift" + "Loop/Managers/AutoPresets/AutoPresets_Delegate.swift" + "Loop/Managers/AutoPresets/AutoPresets_GeofenceManager.swift" + "Loop/Managers/AutoPresets/AutoPresets_CalendarManager.swift" + "Loop/Managers/AutoPresets/AutoPresets_Logger.swift" + "Loop/Managers/AutoPresets/AutoPresets_Storage.swift" + + # GraphDetailView — Managers + "Loop/Managers/GraphDetailViewModel.swift" + + # LoopInsights — Managers + "Loop/Managers/LoopInsights/LoopInsights_BackgroundMonitor.swift" + "Loop/Managers/LoopInsights/LoopInsights_Coordinator.swift" + + # AutoPresets — Models + "Loop/Models/AutoPresets/AutoPresets_Models.swift" + "Loop/Models/AutoPresets/AutoPresets_RecommendationModels.swift" + + # AutoPresets — Services + "Loop/Services/AutoPresets/AutoPresets_AIAdvisor.swift" + + # FoodFinder — Models + "Loop/Models/FoodFinder/FoodFinder_AnalysisRecord.swift" + "Loop/Models/FoodFinder/FoodFinder_InputResults.swift" + "Loop/Models/FoodFinder/FoodFinder_Models.swift" + + # LoopInsights — Models + "Loop/Models/LoopInsights/LoopInsights_Models.swift" + "Loop/Models/LoopInsights/LoopInsights_MFPModels.swift" + "Loop/Models/LoopInsights/LoopInsights_Phase5Models.swift" + "Loop/Models/LoopInsights/LoopInsights_SuggestionRecord.swift" + + # FoodFinder — Resources + "Loop/Resources/FoodFinder/FoodFinder_FeatureFlags.swift" + + # LoopInsights — Resources + "Loop/Resources/LoopInsights/LoopInsights_FeatureFlags.swift" + "Loop/Resources/LoopInsights/PowerPack_BuildInfo.swift" + "Loop/Resources/LoopInsights/PowerPack_UpdateChecker.swift" + "Loop/Resources/LoopInsights/PowerPack_APIUsage.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" + + # FoodFinder — Services + "Loop/Services/FoodFinder/FoodFinder_CarbTrackingService.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_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_SpoonacularService.swift" + "Loop/Services/FoodFinder/FoodFinder_VoiceService.swift" + + # LoopInsights — Services + "Loop/Services/LoopInsights/LoopInsights_AdvancedAnalyzers.swift" + "Loop/Services/LoopInsights/LoopInsights_AIAnalysis.swift" + "Loop/Services/LoopInsights/LoopInsights_AIServiceAdapter.swift" + "Loop/Services/LoopInsights/LoopInsights_AlcoholTracker.swift" + "Loop/Services/LoopInsights/LoopInsights_ChatHistoryStore.swift" + "Loop/Services/LoopInsights/LoopInsights_CaffeineTracker.swift" + "Loop/Services/LoopInsights/LoopInsights_VoiceService.swift" + "Loop/Services/LoopInsights/LoopInsights_BackfillDetector.swift" + "Loop/Services/LoopInsights/LoopInsights_BehaviorInsightsAnalyzer.swift" + "Loop/Services/LoopInsights/LoopInsights_CaregiverDigestService.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_NightscoutImporter.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_MealDebriefService.swift" + "Loop/Services/LoopInsights/LoopInsights_MFPImporter.swift" + "Loop/Services/LoopInsights/LoopInsights_PreMealAdvisorService.swift" + + # FoodFinder — View Models + "Loop/View Models/FoodFinder/FoodFinder_SearchViewModel.swift" + + # LoopInsights — View Models + "Loop/View Models/LoopInsights/LoopInsights_ChatViewModel.swift" + "Loop/View Models/LoopInsights/LoopInsights_DashboardViewModel.swift" + "Loop/View Models/LoopInsights/LoopInsights_MealInsightsViewModel.swift" + + # AutoPresets — Views + "Loop/Views/AutoPresets/AutoPresets_AIRecommendationView.swift" + "Loop/Views/AutoPresets/AutoPresets_GeofenceSettingsView.swift" + "Loop/Views/AutoPresets/AutoPresets_CalendarSettingsView.swift" + "Loop/Views/AutoPresets/AutoPresets_ScannedEventsView.swift" + "Loop/Views/AutoPresets/AutoPresets_SettingsView.swift" + + # AutoPresets — Resources + "Loop/Resources/AutoPresets/AutoPresets_FeatureFlags.swift" + + # BolusPro — Documentation + "Documentation/BolusPro/BolusPro_README.md" + "Documentation/BolusPro/BolusPro_DEVELOPER.md" + + # BolusPro — Models + "Loop/Models/BolusPro/BolusPro_Models.swift" + + # BolusPro — Resources + "Loop/Resources/BolusPro/BolusPro_FeatureFlags.swift" + + # BolusPro — Services + "Loop/Services/BolusPro/BolusPro_FPUCalculator.swift" + "Loop/Services/BolusPro/BolusPro_DataLayerHook.swift" + "Loop/Services/BolusPro/BolusPro_BehaviorAnalyzer.swift" + + # BolusPro — Views + "Loop/Views/BolusPro/BolusPro_InfoSheet.swift" + "Loop/Views/BolusPro/BolusPro_OnboardingView.swift" + "Loop/Views/BolusPro/BolusPro_ManualMacroFields.swift" + "Loop/Views/BolusPro/BolusPro_CarbEntrySection.swift" + "Loop/Views/BolusPro/BolusPro_SettingsView.swift" + + # GraphDetailView — Views + "Loop/Views/GraphDetailView.swift" + + # FoodFinder — Views + "Loop/Views/FoodFinder/FoodFinder_CarbTrackingDashboard.swift" + "Loop/Views/FoodFinder/FoodFinder_AICameraView.swift" + "Loop/Views/FoodFinder/FoodFinder_ImageCropView.swift" + "Loop/Views/FoodFinder/FoodFinder_EntryPoint.swift" + "Loop/Views/FoodFinder/FoodFinder_FavoritesHelpers.swift" + "Loop/Views/FoodFinder/FoodFinder_ScannerView.swift" + "Loop/Views/FoodFinder/FoodFinder_SearchBar.swift" + "Loop/Views/FoodFinder/FoodFinder_RestaurantMenuView.swift" + "Loop/Views/FoodFinder/FoodFinder_SearchResultsView.swift" + "Loop/Views/FoodFinder/FoodFinder_SettingsView.swift" + "Loop/Views/FoodFinder/FoodFinder_VoiceSearchView.swift" + + # LoopInsights — Views + "Loop/Views/LoopInsights/LoopInsights_AGPChartView.swift" + "Loop/Views/LoopInsights/LoopInsights_AlcoholLogView.swift" + "Loop/Views/LoopInsights/LoopInsights_BehaviorInsightsView.swift" + "Loop/Views/LoopInsights/LoopInsights_CaregiverDigestView.swift" + "Loop/Views/LoopInsights/LoopInsights_EndoReportView.swift" + "Loop/Views/LoopInsights/LoopInsights_SignalGapHistoryView.swift" + "Loop/Views/LoopInsights/LoopInsights_SubstackPromo.swift" + "Loop/Views/LoopInsights/LoopInsights_ChatHistoryView.swift" + "Loop/Views/LoopInsights/LoopInsights_CaffeineLogView.swift" + "Loop/Views/LoopInsights/LoopInsights_ChatView.swift" + "Loop/Views/LoopInsights/LoopInsights_DashboardView.swift" + "Loop/Views/LoopInsights/LoopInsights_GoalsView.swift" + "Loop/Views/LoopInsights/LoopInsights_MealInsightsView.swift" + "Loop/Views/LoopInsights/LoopInsights_MonitorSettingsView.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" + "Loop/Views/LoopInsights/LoopInsights_MealDebriefCard.swift" + "Loop/Views/LoopInsights/LoopInsights_PreMealAdvisorCard.swift" + + # LoopInsights — Models + "Loop/Models/LoopInsights/LoopInsights_MealDebriefModels.swift" + + # DataLayer — Managers + "Loop/Managers/DataLayer/DataLayer_Coordinator.swift" + + # DataLayer — Models + "Loop/Models/DataLayer/DataLayer_EventModels.swift" + "Loop/Models/DataLayer/DataLayer_ConsentModels.swift" + + # DataLayer — Resources + "Loop/Resources/DataLayer/DataLayer_FeatureFlags.swift" + + # DataLayer — Services + "Loop/Services/DataLayer/DataLayer_SecureStorage.swift" + "Loop/Services/DataLayer/DataLayer_ConsentManager.swift" + "Loop/Services/DataLayer/DataLayer_EventStore.swift" + "Loop/Services/DataLayer/DataLayer_EventCollector.swift" + "Loop/Services/DataLayer/DataLayer_SyncService.swift" + "Loop/Services/DataLayer/DataLayer_ReportGenerator.swift" + "Loop/Services/DataLayer/DataLayer_ProviderProtocol.swift" + + # DataLayer — Views + "Loop/Views/DataLayer/DataLayer_ConsentView.swift" + "Loop/Views/DataLayer/DataLayer_DashboardView.swift" + + # AutoPresets — Documentation + "Documentation/AutoPresets/AutoPresets_README.md" + "Documentation/AutoPresets/AutoPresets_DEVELOPER.md" + + # FoodFinder — Documentation (DEVELOPER added in Batch 3) + "Documentation/FoodFinder/FoodFinder_DEVELOPER.md" + + # LoopInsights — Documentation (DEVELOPER added in Batch 3) + "Documentation/LoopInsights/LoopInsights_DEVELOPER.md" + + # DataLayer — Documentation (new in Batch 3) + "Documentation/DataLayer/DataLayer_README.md" + "Documentation/DataLayer/DataLayer_DEVELOPER.md" + + # GraphDetailView — Documentation (new in Batch 3) + "Documentation/GraphDetailView/GraphDetailView_README.md" + "Documentation/GraphDetailView/GraphDetailView_DEVELOPER.md" + + # SiteAtlas — Documentation (renamed in Batch 3) + "Documentation/SiteAtlas/SiteAtlas_DEVELOPER.md" + "Documentation/SiteAtlas/SiteAtlas_README.md" + + # SiteAtlas — Models + "Loop/Models/SiteAtlas/SiteAtlas_Models.swift" + + # SiteAtlas — Services + "Loop/Services/SiteAtlas/SiteAtlas_Coordinator.swift" + "Loop/Services/SiteAtlas/SiteAtlas_FeatureFlags.swift" + "Loop/Services/SiteAtlas/SiteAtlas_Storage.swift" + + # SiteAtlas — Views + "Loop/Views/SiteAtlas/SiteAtlas_BodyMapView.swift" + "Loop/Views/SiteAtlas/SiteAtlas_SettingsView.swift" + "Loop/Views/SiteAtlas/SiteAtlas_SiteSelectionSheet.swift" + + # FoodFinder — Tests + "LoopTests/FoodFinder/FoodFinder_BarcodeScannerTests.swift" + "LoopTests/FoodFinder/FoodFinder_OpenFoodFactsTests.swift" + "LoopTests/FoodFinder/FoodFinder_VoiceSearchTests.swift" + + # LoopInsights — Tests + "LoopTests/LoopInsights/LoopInsights_DataAggregatorTests.swift" + "LoopTests/LoopInsights/LoopInsights_ModelsTests.swift" + "LoopTests/LoopInsights/LoopInsights_SuggestionStoreTests.swift" +) + +# Modified files to patch via git diff | git apply --3way +# Excludes: project.pbxproj (handled by Python script), SettingsView.swift (anchor-based), +# LoopDataManager.swift (anchor-based — L&L Customizations modify this file heavily), +# and Localizable.xcstrings (direct checkout — too large for 3-way merge on JSON) +PATCH_FILES=( + "Loop/Managers/LoopAppManager.swift" + "Loop/View Controllers/StatusTableViewController.swift" + "Loop/View Models/AddEditFavoriteFoodViewModel.swift" + "Loop/View Models/CarbEntryViewModel.swift" + "Loop/View Models/SettingsViewModel.swift" + "Loop/Views/AddEditFavoriteFoodView.swift" + "Loop/Views/CarbEntryView.swift" + "Loop/Views/FavoriteFoodDetailView.swift" + "Loop/Views/FavoriteFoodsView.swift" +) + +# Files that should be wholesale-replaced from feat/AllFeatures rather than +# patched via 3-way merge. Use sparingly — only for files where L&L +# customizations cannot conflict. +OVERRIDE_FILES=( +) + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERR]${NC} $*"; } +header() { echo -e "\n${BOLD}═══ $* ═══${NC}"; } + +die() { + error "$@" + exit 1 +} + +# ─── Phase 1: Validation ───────────────────────────────────────────────────── + +validate_environment() { + header "Phase 1: Validating environment" + + # Must run from LoopWorkspace root + if [[ ! -d "LoopWorkspace.xcworkspace" ]]; then + die "Must run from LoopWorkspace root directory (LoopWorkspace.xcworkspace not found). + cd into your LoopWorkspace folder and try again." + fi + success "Running from LoopWorkspace root" + + # Loop submodule must exist + if [[ ! -d "Loop/.git" ]] && [[ ! -f "Loop/.git" ]]; then + die "Loop submodule not found. Make sure you've cloned with --recurse-submodules." + fi + success "Loop submodule exists" + + # python3 available + if ! command -v python3 &>/dev/null; then + die "python3 is required but not found. Install Python 3 and try again." + fi + success "python3 available ($(python3 --version 2>&1))" + + # Check for existing feature files (idempotency) + if [[ -f "Loop/${MARKER_FILE}" ]]; then + die "Features are already installed (marker file found). + To reinstall, run: ./Scripts/install_features.sh --rollback first." + fi + + local sample_files=( + "Loop/Loop/Views/FoodFinder/FoodFinder_EntryPoint.swift" + "Loop/Loop/Views/LoopInsights/LoopInsights_DashboardView.swift" + "Loop/Loop/Views/AutoPresets/AutoPresets_SettingsView.swift" + ) + for f in "${sample_files[@]}"; do + if [[ -f "$f" ]]; then + die "Feature files already exist ($f found). + To reinstall, run: ./Scripts/install_features.sh --rollback first." + fi + done + success "No existing feature files found" + + # Verify SettingsView.swift anchors exist + local settings_file="Loop/Loop/Views/SettingsView.swift" + if [[ ! -f "$settings_file" ]]; then + die "SettingsView.swift not found at expected path." + fi + + if ! grep -q 'Diabetes Treatment' "$settings_file"; then + die "Anchor not found in SettingsView.swift: Diabetes Treatment + Your Loop version may be incompatible." + fi + + if ! grep -q 'private var cgmChoices' "$settings_file"; then + die "Anchor not found in SettingsView.swift: private var cgmChoices + Your Loop version may be incompatible." + fi + success "SettingsView.swift anchors verified" + + # Detect L&L patches (informational only) + detect_ll_patches +} + +detect_ll_patches() { + local settings_file="Loop/Loop/Views/SettingsView.swift" + local found_patches=() + + if grep -q "ProfileManager\|Profiles" "$settings_file" 2>/dev/null; then + found_patches+=("Profiles") + fi + + if grep -q "basalLock\|BasalLock\|basal_lock" "Loop/Loop/Managers/LoopDataManager.swift" 2>/dev/null; then + found_patches+=("Basal Lock") + fi + + if grep -q "negativeInsulin\|NegativeInsulin\|negative_insulin" "Loop/Loop/Managers/LoopDataManager.swift" 2>/dev/null; then + found_patches+=("Negative Insulin") + fi + + local carb_file="Loop/Loop/Views/CarbEntryView.swift" + if grep -q "futureCarb\|FutureCarb\|future_carb_4h\|absorptionTimeWasEdited" "$carb_file" 2>/dev/null; then + found_patches+=("Future Carbs 4h") + fi + + if [[ ${#found_patches[@]} -gt 0 ]]; then + info "Detected L&L patches: ${found_patches[*]}" + info "These are compatible — the installer will adapt to them." + else + info "No L&L patches detected (standard Loop)." + fi +} + +# ─── Phase 2: Backup ───────────────────────────────────────────────────────── + +create_backup() { + header "Phase 2: Creating backup" + + pushd Loop > /dev/null + + # Stash any uncommitted changes (including L&L patches) as a safety backup, + # then immediately restore them so L&L patches remain in the working tree + # during installation. The stash entry stays for rollback. + local stash_msg="pre-feature-install-$(date +%Y%m%d-%H%M%S)" + if ! git diff --quiet || ! git diff --cached --quiet; then + git stash push -m "$stash_msg" --include-untracked + git stash apply 2>/dev/null + success "Backed up working tree as: $stash_msg (L&L patches preserved)" + else + info "Working tree clean, no stash needed." + fi + + popd > /dev/null +} + +# ─── Phase 3: Fetch Source ──────────────────────────────────────────────────── + +setup_source_remote() { + header "Phase 3: Fetching feature source" + + pushd Loop > /dev/null + + # Remove stale remote if it exists + if git remote | grep -q "^${FEATURE_REMOTE}$"; then + git remote remove "$FEATURE_REMOTE" + fi + + git remote add "$FEATURE_REMOTE" "$FEATURE_REPO" + git fetch "$FEATURE_REMOTE" "$FEATURE_LOOP_BRANCH" --depth=1 + success "Fetched ${FEATURE_LOOP_BRANCH} from ${FEATURE_REPO}" + + # Also fetch dev — our feature branch was based on dev, so we need it as the diff base + # even when the user cloned main (which is the L&L-compatible path) + git fetch "$FEATURE_REMOTE" dev --depth=1 + success "Fetched dev ref for diff base" + + popd > /dev/null +} + +# ─── Phase 3b: Bump version ───────────────────────────────────────────────── +# +# (The former Phase 3b that cherry-picked OmniBLE's pod-keep-alive branch +# was removed when LoopKit/OmniBLE merged that fix into mainline as PR #165 +# in OmniBLE v.r.r — the workspace v3.14.0 bump already brings it in. The +# upstream `feat/pod-keep-alive` branch was deleted per the L&L release +# announcement on 14 May 2026.) + +bump_version() { + header "Phase 3b: Loop version (left as cloned)" + + # Do NOT overwrite the Loop marketing/build version — keep whatever upstream + # the user cloned so Settings reflects their real base. Just report it. + if [[ -f "VersionOverride.xcconfig" ]]; then + local current + current=$(grep -E '^LOOP_MARKETING_VERSION' VersionOverride.xcconfig | sed 's/.*=[[:space:]]*//') + info "Keeping Loop version as cloned (${current:-unknown}); PowerPack ${POWERPACK_VERSION}" + else + info "VersionOverride.xcconfig not found — leaving version untouched" + fi +} + +# ─── Phase 4: Install New Files ────────────────────────────────────────────── + +install_new_files() { + header "Phase 4: Installing ${#NEW_FILES[@]} new files" + + pushd Loop > /dev/null + + local installed=0 + local failed=0 + + for file in "${NEW_FILES[@]}"; do + if git checkout "${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}" -- "$file" 2>/dev/null; then + ((installed++)) + else + warn "Failed to checkout: $file" + ((failed++)) + fi + done + + # Localizable.xcstrings: direct checkout instead of 3-way merge + # (71K-line JSON file — too large for reliable diff/apply) + # Only replace if the user already has it (dev branch uses xcstrings; + # main branch uses old-style .strings files and doesn't have xcstrings) + if [[ -f "Loop/Localizable.xcstrings" ]]; then + if git checkout "${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}" -- "Loop/Localizable.xcstrings" 2>/dev/null; then + ((installed++)) + success "Replaced Localizable.xcstrings (direct checkout)" + else + warn "Failed to checkout Localizable.xcstrings" + ((failed++)) + fi + else + info "Skipping Localizable.xcstrings (not present on this branch — features use NSLocalizedString fallback)" + fi + + popd > /dev/null + + success "Installed $installed files" + if [[ $failed -gt 0 ]]; then + warn "$failed files failed to install" + fi +} + +# ─── Phase 4b: Install SiteAtlas Body Map Assets ───────────────────────────── + +install_body_map_assets() { + header "Phase 4b: Installing SiteAtlas body map assets" + + pushd Loop > /dev/null + + local assets_base="Loop/DerivedAssetsBase.xcassets" + + # Pull the PNGs from the feature branch into a temp location + local tmp_front tmp_back + tmp_front=$(mktemp) + tmp_back=$(mktemp) + + if git show "${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}:Loop/Resources/SiteAtlas/BodyMapFront.png" > "$tmp_front" 2>/dev/null && \ + git show "${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}:Loop/Resources/SiteAtlas/BodyMapBack.png" > "$tmp_back" 2>/dev/null; then + + # Create imageset directories + mkdir -p "$assets_base/BodyMapFront.imageset" + mkdir -p "$assets_base/BodyMapBack.imageset" + + # Copy PNGs + cp "$tmp_front" "$assets_base/BodyMapFront.imageset/BodyMapFront.png" + cp "$tmp_back" "$assets_base/BodyMapBack.imageset/BodyMapBack.png" + + # Write Contents.json for each + cat > "$assets_base/BodyMapFront.imageset/Contents.json" << 'IMGEOF' +{ + "images" : [ + { + "filename" : "BodyMapFront.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} +IMGEOF + + cat > "$assets_base/BodyMapBack.imageset/Contents.json" << 'IMGEOF' +{ + "images" : [ + { + "filename" : "BodyMapBack.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} +IMGEOF + + success "Installed BodyMapFront + BodyMapBack imagesets into DerivedAssetsBase.xcassets" + else + warn "Could not retrieve body map PNGs from feature branch — SiteAtlas will use fallback icon" + fi + + rm -f "$tmp_front" "$tmp_back" + popd > /dev/null +} + +# ─── Phase 4c: Stamp PowerPack build info ──────────────────────────────────── +# Rewrites Loop/Loop/Resources/LoopInsights/PowerPack_BuildInfo.swift to +# replace the committed-default constants ("dev" / empty) with real +# install-time values: the actual Loop submodule short SHA + today's UTC +# date + POWERPACK_VERSION from this script. +# +# Effect: the in-app version footer flips from "PowerPack v0.1.0-dev" to +# "PowerPack v0.1.0 (8bd0a85)" — the SHA maps directly to a commit on the +# LoopPowerPack/Loop repo, so user-reported versions can be reproduced. +# +# Runs after Phase 4 (which copies the file into place) so we're patching +# the just-copied file, not the source in our fetch-source remote. + +stamp_build_info() { + header "Phase 4c: Stamping PowerPack build info" + + local build_info_file="Loop/Loop/Resources/LoopInsights/PowerPack_BuildInfo.swift" + if [[ ! -f "$build_info_file" ]]; then + warn "PowerPack_BuildInfo.swift not found at $build_info_file — install may be incomplete" + return 0 + fi + + pushd Loop > /dev/null + local commit_sha + commit_sha=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + popd > /dev/null + + local build_date + build_date=$(date -u +%Y-%m-%d) + + # Rewrite the three constants. `.bak` extension is required on macOS sed + # for in-place editing; we delete the backup immediately after. + sed -i.bak \ + -e "s|static let version = \"[^\"]*\"|static let version = \"${POWERPACK_VERSION}\"|" \ + -e "s|static let commitShortSHA = \"[^\"]*\"|static let commitShortSHA = \"${commit_sha}\"|" \ + -e "s|static let buildDate = \"[^\"]*\"|static let buildDate = \"${build_date}\"|" \ + "$build_info_file" + rm -f "${build_info_file}.bak" + + success "Stamped PowerPack v${POWERPACK_VERSION} (${commit_sha}) built ${build_date}" +} + +# ─── Phase 4d: Override Files (wholesale checkout) ──────────────────────────── + +override_modified_files() { + if [[ ${#OVERRIDE_FILES[@]} -eq 0 ]]; then + return + fi + header "Phase 4d: Replacing ${#OVERRIDE_FILES[@]} files wholesale from feat/AllFeatures" + + pushd Loop > /dev/null + + local replaced=0 + local failed=0 + + for file in "${OVERRIDE_FILES[@]}"; do + if git checkout "${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}" -- "$file" 2>/dev/null; then + success "Replaced: $file" + ((replaced++)) + else + warn "Could not replace: $file" + ((failed++)) + fi + done + + popd > /dev/null + + info "Replaced: $replaced, Failed: $failed" + if [[ $failed -gt 0 ]]; then + die "Some override files could not be replaced — install aborted." + fi +} + +# ─── Phase 5: Patch Modified Files ─────────────────────────────────────────── + +patch_modified_files() { + header "Phase 5: Patching ${#PATCH_FILES[@]} modified files" + + pushd Loop > /dev/null + + # We need the dev branch as the diff base. feat/AllFeatures was branched from dev, + # so `git diff dev..feat/AllFeatures` isolates ONLY our feature changes. + # We fetched dev from our remote in Phase 3, so it's always available — + # even when the user cloned main (the L&L-compatible path). + local dev_ref + dev_ref=$(git rev-parse "${FEATURE_REMOTE}/dev" 2>/dev/null) + if [[ -z "$dev_ref" ]]; then + # Fallback to local dev branches + dev_ref=$(git rev-parse dev 2>/dev/null || git rev-parse origin/dev 2>/dev/null || git rev-parse upstream/dev 2>/dev/null) + fi + if [[ -z "$dev_ref" ]]; then + die "Cannot find dev branch reference. The feature remote fetch may have failed." + fi + + local patched=0 + local failed=0 + local skipped=0 + + for file in "${PATCH_FILES[@]}"; do + local diff_output + diff_output=$(git diff "$dev_ref".."${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}" -- "$file" 2>/dev/null) + + if [[ -z "$diff_output" ]]; then + info "No changes for: $file (skipped)" + ((skipped++)) + continue + fi + + if echo "$diff_output" | git apply --3way 2>/dev/null; then + success "Patched: $file" + ((patched++)) + else + warn "3-way merge had conflicts for: $file" + warn " → Check for conflict markers and resolve manually." + ((failed++)) + fi + done + + popd > /dev/null + + info "Patched: $patched, Skipped: $skipped, Conflicts: $failed" + if [[ $failed -gt 0 ]]; then + warn "Some files had merge conflicts. Resolve them before building." + fi +} + +# ─── Phase 5b: Patch Info.plist privacy usage descriptions ─────────────────── +# Upstream Loop's Info.plist only declares NSCameraUsageDescription (for +# barcode scanning). FoodFinder, AutoPresets, and LoopInsights need +# additional privacy keys — without them iOS hard-crashes with SIGABRT the +# first time PHPhotoLibrary, the microphone, speech recognition, or +# CoreLocation is touched. Without this patch, Option B installs crash on +# the FoodFinder camera button (PHPhotoLibrary access for the recent-photo +# thumbnail in the camera overlay). +# +# Pattern: add-if-missing. We never overwrite an existing value — that +# protects any custom strings a user (or an L&L patch) may have set. Keys +# we add are removed automatically by the rollback flow's `git checkout +# HEAD -- .` since they were never committed. + +patch_info_plist() { + header "Phase 5b: Patching Loop Info.plist privacy keys" + + local plist="Loop/Loop/Info.plist" + if [[ ! -f "$plist" ]]; then + die "Loop Info.plist not found at $plist" + fi + + if ! plutil -lint "$plist" >/dev/null 2>&1; then + die "Info.plist failed plutil syntax check BEFORE patching — refusing to modify." + fi + + PLIST_PATH="$plist" python3 - <<'PYEOF' || die "Info.plist patch failed." +import os +import plistlib +import sys +from pathlib import Path + +PLIST = Path(os.environ["PLIST_PATH"]) + +KEYS = { + "NSPhotoLibraryUsageDescription": + "FoodFinder previews your most recent photo on the camera screen so you can pick a photo for analysis without leaving the capture view.", + "NSMicrophoneUsageDescription": + "FoodFinder uses the microphone for voice-powered food logging.", + "NSSpeechRecognitionUsageDescription": + "FoodFinder uses speech recognition to log meals by voice.", + "NSLocationWhenInUseUsageDescription": + "FoodFinder uses your location to tag meals with where you ate. AutoPresets uses location to activate presets when you arrive at or leave saved places like the gym.", + "NSLocationAlwaysAndWhenInUseUsageDescription": + "AutoPresets uses background location to monitor geofences and automatically activate presets when you arrive at or leave saved locations. This is battery-efficient and does not continuously track your position.", + "NSMotionUsageDescription": + "AutoPresets uses motion and fitness activity to detect when you start exercising so it can automatically suggest the right preset.", + "NSCalendarsUsageDescription": + "AutoPresets reads your calendar to activate presets around scheduled events like workouts or meals.", + "NSCalendarsFullAccessUsageDescription": + "AutoPresets reads your calendar to activate presets around scheduled events like workouts or meals.", +} + +with open(PLIST, "rb") as f: + data = plistlib.load(f) + +added, kept = [], [] +for key, value in KEYS.items(): + if key in data: + kept.append(key) + else: + data[key] = value + added.append(key) + +with open(PLIST, "wb") as f: + plistlib.dump(data, f) + +for k in added: + print(f" added: {k}") +for k in kept: + print(f" kept: {k} (already present, left unchanged)") + +print(f"summary: {len(added)} added, {len(kept)} kept") +PYEOF + + if ! plutil -lint "$plist" >/dev/null 2>&1; then + die "Info.plist failed plutil syntax check AFTER patching — install aborted." + fi + + success "Phase 5b complete: Info.plist privacy keys verified" +} + +# ─── Phase 6: Patch SettingsView.swift (Anchor-Based) ──────────────────────── + +patch_settings_view() { + header "Phase 6: Patching SettingsView.swift (anchor-based)" + + local settings_file="Loop/Loop/Views/SettingsView.swift" + + # Use Python for reliable multi-line text insertion + python3 - "$settings_file" << 'PYTHON_SCRIPT' +import sys + +settings_path = sys.argv[1] + +with open(settings_path, "r") as f: + content = f.read() + +lines = content.split("\n") + +# ─── Anchor 1: Insert feature rows AFTER the Therapy Settings button ─── +# We anchor on "Diabetes Treatment" (the Therapy Settings descriptive text) so our +# features appear right after Therapy Settings. If L&L Profiles is installed, it +# inserts before the ForEach — so Profiles ends up BELOW our features. + +FEATURE_ROWS = """ + 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 Auto-Apply Presets") + ) + } + + bolusProSettingsRow + + foodFinderSettingsRow + + loopInsightsSection + + siteAtlasSettingsRow +""" + +anchor1 = 'Diabetes Treatment' +anchor1_idx = None +for i, line in enumerate(lines): + if anchor1 in line: + anchor1_idx = i + break + +if anchor1_idx is None: + print(f"ERROR: Anchor 1 not found: {anchor1}", file=sys.stderr) + sys.exit(1) + +# Insert the feature rows AFTER the Therapy Settings descriptive text line +feature_lines = FEATURE_ROWS.rstrip("\n").split("\n") +insert_at = anchor1_idx + 2 # after the NavigationLink closing brace (line after "Diabetes Treatment") +for j, fl in enumerate(feature_lines): + lines.insert(insert_at + j, fl) +print(f" Inserted {len(feature_lines)} lines after Therapy Settings (line {anchor1_idx + 1})") + +# ─── Anchor 2: Insert computed properties BEFORE "private var cgmChoices:" ─── + +COMPUTED_PROPS = """ + // BolusPro — single settings insertion point + private var bolusProSettingsRow: some View { + 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")) + } + } + + // FoodFinder — single settings insertion point + private var foodFinderSettingsRow: some View { + 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")) + } + } + + private var loopInsightsSection: some View { + 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")) + } + } + } + + private var siteAtlasSettingsRow: some View { + 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")) + } + } + +""" + +anchor2 = "private var cgmChoices:" +anchor2_idx = None +# Re-scan from scratch since lines array was modified +for i, line in enumerate(lines): + if anchor2 in line: + anchor2_idx = i + break + +if anchor2_idx is None: + print(f"ERROR: Anchor 2 not found: {anchor2}", file=sys.stderr) + sys.exit(1) + +prop_lines = COMPUTED_PROPS.rstrip("\n").split("\n") +for j, pl in enumerate(prop_lines): + lines.insert(anchor2_idx + j, pl) +print(f" Inserted {len(prop_lines)} lines before cgmChoices anchor (line {anchor2_idx + 1})") + +# Write back +with open(settings_path, "w") as f: + f.write("\n".join(lines)) + +print(" SettingsView.swift patched successfully.") +PYTHON_SCRIPT + + if [[ $? -eq 0 ]]; then + success "SettingsView.swift patched with anchor-based insertion" + else + error "Failed to patch SettingsView.swift" + return 1 + fi +} + +# ─── Phase 6c: Patch BolusEntryViewModel.swift (Anchor-Based) ──────────────── +# +# L&L Customizations modify the BolusEntryViewModelDelegate protocol signature +# in this file (Result instead of Error?). Wholesale +# replacement clobbers that change; 3-way merge can fail on context drift. +# Anchor-based insertion adds BolusPro additions surgically while leaving the +# protocol declaration and any L&L modifications intact. + +patch_bolus_entry_viewmodel() { + header "Phase 6c: Patching BolusEntryViewModel.swift (anchor-based, BolusPro)" + + local target_file="Loop/Loop/View Models/BolusEntryViewModel.swift" + + if [[ ! -f "$target_file" ]]; then + warn "BolusEntryViewModel.swift not found — skipping BolusPro patch" + return + fi + + python3 - "$target_file" << 'PYTHON_SCRIPT' +import sys + +path = sys.argv[1] +with open(path, "r") as f: + content = f.read() + +lines = content.split("\n") + +# Each anchor below is independently idempotent — the marker string lets us +# skip just the addition that's already in place, so users upgrading from +# an older PowerPack install still receive any newly-introduced additions. + +def find_line(needle): + for i, line in enumerate(lines): + if needle in line: + return i + return None + +def insert_after(idx, block): + body = block.rstrip("\n").split("\n") + for offset, line in enumerate(body): + lines.insert(idx + 1 + offset, line) + return len(body) + +inserted_any = False + +# ─── Anchor 1: BolusPro properties after selectedCarbAbsorptionTimeEmoji ─── +if "bolusProSecondaryEntry" not in content: + BOLUSPRO_PROPS = """ + /// BolusPro — optional secondary FPU carb entry, set by + /// `CarbEntryViewModel.setBolusViewModel()` when the user has the + /// per-entry toggle on and macros that yield a non-trivial bonus. + /// Saved alongside the primary in `saveAndDeliver()`. + var bolusProSecondaryEntry: NewCarbEntry? + + /// BolusPro — analytics snapshot fired to DataLayer + LoopInsights + /// after the primary entry persists, regardless of whether the + /// per-entry toggle was on. Populated by CarbEntryViewModel. + var bolusProAnalyticsSnapshot: BolusProAnalyticsSnapshot? +""" + idx = find_line("let selectedCarbAbsorptionTimeEmoji: String?") + if idx is None: + print("ERROR: Anchor 'selectedCarbAbsorptionTimeEmoji' not found", file=sys.stderr) + sys.exit(1) + n = insert_after(idx, BOLUSPRO_PROPS) + print(f" Inserted {n} BolusPro property lines (after line {idx + 1})") + inserted_any = True + +# ─── Anchor 2: onCarbEntrySaved property after bolusProAnalyticsSnapshot ─── +# Added in efa338aa to gate MealArchive writes on actual carb persistence. +if "onCarbEntrySaved" not in content: + ON_SAVED_PROP = """ + /// Fires immediately after the *primary* carb entry has been persisted + /// to CarbStore (i.e., the user committed the carbs — not when they + /// merely tapped Continue and then cancelled the bolus screen). + /// `CarbEntryViewModel.setBolusViewModel()` uses this to archive the + /// FoodFinder analysis to MealArchive only on actual commit. Receives + /// the persisted entry so the caller can use its real syncIdentifier. + var onCarbEntrySaved: ((StoredCarbEntry) -> Void)? +""" + idx = find_line("var bolusProAnalyticsSnapshot: BolusProAnalyticsSnapshot?") + if idx is None: + print("ERROR: Anchor 'bolusProAnalyticsSnapshot' not found", file=sys.stderr) + sys.exit(1) + n = insert_after(idx, ON_SAVED_PROP) + print(f" Inserted {n} onCarbEntrySaved property lines (after line {idx + 1})") + inserted_any = True + +# ─── Anchor 3: onCarbEntrySaved call site after the "Phone" didAddCarbs line ─── +# Must be inserted BEFORE the BolusPro save block (Anchor 4) so the archive +# fires for the primary, not the BolusPro secondary. +if 'self.onCarbEntrySaved?(storedCarbEntry)' not in content: + ON_SAVED_CALL = """ + // FoodFinder/MealInsights archive only fires on actual carb + // persistence — tapping Continue and then cancelling the + // bolus screen no longer leaves a phantom Meal Insights row. + self.onCarbEntrySaved?(storedCarbEntry) +""" + idx = find_line('self.analyticsServicesManager?.didAddCarbs(source: "Phone"') + if idx is None: + print("ERROR: Anchor 'didAddCarbs Phone' not found", file=sys.stderr) + sys.exit(1) + n = insert_after(idx, ON_SAVED_CALL) + print(f" Inserted {n} onCarbEntrySaved call lines (after line {idx + 1})") + inserted_any = True + +# ─── Anchor 4: BolusPro save logic after the primary didAddCarbs/onCarbEntrySaved ─── +# Anchor on the BolusPro-specific marker we just inserted (or the existing +# Phone-didAddCarbs if no prior install of Anchor 3). +if "BolusPro — save the optional secondary FPU entry" not in content: + BOLUSPRO_SAVE = """ + // BolusPro — save the optional secondary FPU entry alongside + // the primary. Failure here doesn't roll back the primary + // (the user already committed to that bolus); we just log. + 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.") + } + } + + // BolusPro — fire analytics + BehaviorInsights notification + // even when per-entry toggle was off, so we capture + // adoption vs. non-adoption population data. + if let snapshot = bolusProAnalyticsSnapshot { + BolusPro_DataLayerHook.recordSavedEntry(snapshot) + } +""" + # Prefer to insert right after the onCarbEntrySaved call line if present + # in this run; otherwise fall back to the Phone didAddCarbs anchor. + idx = find_line('self.onCarbEntrySaved?(storedCarbEntry)') + if idx is None: + idx = find_line('self.analyticsServicesManager?.didAddCarbs(source: "Phone"') + if idx is None: + print("ERROR: BolusPro save anchor not found", file=sys.stderr) + sys.exit(1) + n = insert_after(idx, BOLUSPRO_SAVE) + print(f" Inserted {n} BolusPro save-logic lines (after line {idx + 1})") + inserted_any = True + +if not inserted_any: + print(" Already fully patched — nothing to do.") + sys.exit(0) + +with open(path, "w") as f: + f.write("\n".join(lines)) + +print(" BolusEntryViewModel.swift patched successfully.") +PYTHON_SCRIPT +} + +# ─── Phase 6b: Patch LoopDataManager.swift (Anchor-Based) ──────────────────── +# +# L&L Customizations heavily modify LoopDataManager.swift (Negative Insulin Damper, +# function signature changes, etc.), so git apply --3way fails silently. +# Instead, we use anchor-based insertion like SettingsView.swift. + +patch_loop_data_manager() { + header "Phase 6b: Patching LoopDataManager.swift (anchor-based)" + + local ldm_file="Loop/Loop/Managers/LoopDataManager.swift" + + if [[ ! -f "$ldm_file" ]]; then + die "LoopDataManager.swift not found at: $ldm_file" + fi + + # Skip if already patched + if grep -q "AutoPresets_Coordinator" "$ldm_file"; then + info "LoopDataManager.swift already contains AutoPresets code — skipping." + return 0 + fi + + python3 - "$ldm_file" << 'PYTHON_SCRIPT' +import sys + +ldm_path = sys.argv[1] + +with open(ldm_path, "r") as f: + content = f.read() + +lines = content.split("\n") + +# ─── Anchor 1: Insert delegate setup after "self.trustedTimeOffset = trustedTimeOffset" ─── +# This is in the init method. The delegate line goes right after this assignment, +# before the LiveActivity setup. + +DELEGATE_SETUP = """\ + + // Set up AutoPresets coordinator delegate + AutoPresets_Coordinator.shared.delegate = self + + // Initialize SiteAtlas coordinator + _ = SiteAtlas_Coordinator.shared +""" + +anchor1 = "self.trustedTimeOffset = trustedTimeOffset" +anchor1_idx = None +for i, line in enumerate(lines): + if anchor1 in line: + anchor1_idx = i + break + +if anchor1_idx is None: + print(f"ERROR: Anchor not found: {anchor1}", file=sys.stderr) + sys.exit(1) + +delegate_lines = DELEGATE_SETUP.rstrip("\n").split("\n") +insert_at = anchor1_idx + 1 +for j, dl in enumerate(delegate_lines): + lines.insert(insert_at + j, dl) +print(f" Inserted delegate setup ({len(delegate_lines)} lines) after line {anchor1_idx + 1}") + +# ─── Anchor 2: Append AutoPresetsDelegate extension at end of file ─── +# We find the very last closing brace of the file and append after it. + +DELEGATE_EXTENSION = """ +// MARK: - AutoPresets_Delegate + +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 + } +} +""" + +extension_lines = DELEGATE_EXTENSION.split("\n") +lines.extend(extension_lines) +print(f" Appended AutoPresets_Delegate extension ({len(extension_lines)} lines) at end of file") + +# Write back +with open(ldm_path, "w") as f: + f.write("\n".join(lines)) + +print(" LoopDataManager.swift patched successfully.") +PYTHON_SCRIPT + + if [[ $? -eq 0 ]]; then + success "LoopDataManager.swift patched with AutoPresets delegate" + else + error "Failed to patch LoopDataManager.swift" + return 1 + fi +} + +# ─── Phase 7: Update project.pbxproj ───────────────────────────────────────── + +update_pbxproj() { + header "Phase 7: Updating project.pbxproj" + + local pbxproj="Loop/Loop.xcodeproj/project.pbxproj" + + if [[ ! -f "$pbxproj" ]]; then + die "project.pbxproj not found at: $pbxproj" + fi + + # Back up pbxproj + cp "$pbxproj" "${pbxproj}.backup" + + # Find the update script — alongside this script, or in Scripts/, or download it + local py_script="" + local script_dir + + # Try 1: alongside this script (normal local run) + if [[ -n "${BASH_SOURCE[0]:-}" ]] && [[ "${BASH_SOURCE[0]}" != "bash" ]]; then + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" + if [[ -f "${script_dir}/update_pbxproj.py" ]]; then + py_script="${script_dir}/update_pbxproj.py" + fi + fi + + # Try 2: in Scripts/ relative to cwd (LoopWorkspace root) + if [[ -z "$py_script" ]] && [[ -f "Scripts/update_pbxproj.py" ]]; then + py_script="Scripts/update_pbxproj.py" + fi + + # Try 3: download from GitHub + if [[ -z "$py_script" ]]; then + info "Downloading update_pbxproj.py..." + mkdir -p Scripts + if curl -fsSL "${FEATURE_WORKSPACE_REPO}/Scripts/update_pbxproj.py" -o Scripts/update_pbxproj.py; then + py_script="Scripts/update_pbxproj.py" + success "Downloaded update_pbxproj.py" + else + die "Failed to download update_pbxproj.py from GitHub." + fi + fi + + if python3 "$py_script" "$pbxproj"; then + success "project.pbxproj updated" + else + error "Failed to update project.pbxproj — restoring backup" + cp "${pbxproj}.backup" "$pbxproj" + return 1 + fi + + # Validate + if plutil -lint "$pbxproj" > /dev/null 2>&1; then + success "project.pbxproj passes plutil validation" + rm -f "${pbxproj}.backup" + else + error "project.pbxproj failed plutil validation — restoring backup" + cp "${pbxproj}.backup" "$pbxproj" + rm -f "${pbxproj}.backup" + return 1 + fi +} + +# ─── Phase 8: Replace App Icon (PowerPack branding) ───────────────────────── + +replace_app_icon() { + header "Phase 8: Installing Loop AI PowerPack icon" + + # Find the source icon — alongside this script, or download it + local src_icon="" + local script_dir + + if [[ -n "${BASH_SOURCE[0]:-}" ]] && [[ "${BASH_SOURCE[0]}" != "bash" ]]; then + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" + if [[ -f "${script_dir}/AppIcon-PowerPack.png" ]]; then + src_icon="${script_dir}/AppIcon-PowerPack.png" + fi + fi + + if [[ -z "$src_icon" ]] && [[ -f "Scripts/AppIcon-PowerPack.png" ]]; then + src_icon="Scripts/AppIcon-PowerPack.png" + fi + + if [[ -z "$src_icon" ]]; then + info "Downloading AppIcon-PowerPack.png..." + mkdir -p Scripts + if curl -fsSL "${FEATURE_WORKSPACE_REPO}/Scripts/AppIcon-PowerPack.png" -o Scripts/AppIcon-PowerPack.png; then + src_icon="Scripts/AppIcon-PowerPack.png" + success "Downloaded PowerPack icon" + else + warn "Could not download PowerPack icon — skipping icon replacement" + return 0 + fi + fi + + local replaced=0 + + # Replace icons in all asset catalogs that have AppIcon.appiconset + for iconset_dir in \ + "OverrideAssetsLoop.xcassets/AppIcon.appiconset" \ + "OverrideAssetsWatchApp.xcassets/AppIcon.appiconset" \ + "Loop/Loop/DerivedAssets.xcassets/AppIcon.appiconset" \ + "Loop/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset" \ + "Loop/WatchApp/DerivedAssets.xcassets/AppIcon.appiconset" \ + "Loop/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset"; do + + if [[ ! -d "$iconset_dir" ]]; then + continue + fi + + # Replace every PNG in this icon set with a resized version of the PowerPack icon + for png in "$iconset_dir"/*.png; do + [[ -f "$png" ]] || continue + # Read the current dimensions and resize the source to match + local w h + w=$(sips -g pixelWidth "$png" 2>/dev/null | tail -1 | awk '{print $2}') + h=$(sips -g pixelHeight "$png" 2>/dev/null | tail -1 | awk '{print $2}') + if [[ -n "$w" ]] && [[ -n "$h" ]] && [[ "$w" -gt 0 ]]; then + sips -z "$h" "$w" "$src_icon" --out "$png" > /dev/null 2>&1 + ((replaced++)) + fi + done + done + + if [[ $replaced -gt 0 ]]; then + success "Replaced $replaced icon files across all asset catalogs" + else + warn "No icon files found to replace" + fi +} + +# ─── Phase 8b: Patch LoopKit (Therapy Help → LoopInsights) ─────────────────── + +patch_loopkit() { + header "Phase 8b: Patching LoopKit for therapy help integration" + + local dismiss_file="LoopKit/LoopKitUI/Extensions/Environment+Dismiss.swift" + local therapy_file="LoopKit/LoopKitUI/Views/Settings Editors/TherapySettingsView.swift" + + if [[ ! -f "$dismiss_file" ]]; then + warn "Environment+Dismiss.swift not found at: $(pwd)/$dismiss_file" + warn "Skipping LoopKit patch" + return + fi + + if [[ ! -f "$therapy_file" ]]; then + warn "TherapySettingsView.swift not found at: $(pwd)/$therapy_file" + fi + + # 1. Add TherapyHelpRegistry to Environment+Dismiss.swift (if not already present) + if ! grep -q "TherapyHelpRegistry" "$dismiss_file"; then + cat >> "$dismiss_file" << 'LOOPKIT_EOF' + +// MARK: - Therapy Help Registry + +/// Static registry so Loop can inject a "Get help" destination without environment propagation. +/// Set `TherapyHelpRegistry.destination` once at app startup; TherapySettingsView reads it directly. +public final class TherapyHelpRegistry { + public static var destination: AnyView? = nil +} +LOOPKIT_EOF + success "Added TherapyHelpRegistry to Environment+Dismiss.swift" + else + info "TherapyHelpRegistry already present — skipping" + fi + + # 2. Patch Loop's SettingsView to register therapy help on appear + # Anchors on .navigationViewStyle(.stack) which is at the end of the body + # The .onAppear fires when SettingsView appears — BEFORE user navigates to TherapySettingsView + local settings_file="Loop/Loop/Views/SettingsView.swift" + if [[ -f "$settings_file" ]] && ! grep -q "TherapyHelpRegistry" "$settings_file"; then + python3 - "$settings_file" << 'PYEOF' +import sys + +filepath = sys.argv[1] +with open(filepath, 'r') as f: + content = f.read() + +old_line = '.navigationViewStyle(.stack)' +new_block = old_line + ''' + .onAppear { + TherapyHelpRegistry.destination = AnyView(LoopInsights_SettingsView(dataStoresProvider: viewModel.loopInsightsDataStores)) + }''' + +if old_line in content: + content = content.replace(old_line, new_block, 1) + with open(filepath, 'w') as f: + f.write(content) + print("OK: Injected TherapyHelpRegistry into SettingsView onAppear") +else: + print("FAIL: .navigationViewStyle(.stack) not found in SettingsView") + sys.exit(1) +PYEOF + if [[ $? -eq 0 ]]; then + success "Patched SettingsView.swift with therapy help registry" + else + warn "Failed to patch SettingsView.swift therapy help" + fi + else + info "SettingsView therapy help already patched — skipping" + fi + + # 3. Patch TherapySettingsView to use the static registry + if [[ -f "$therapy_file" ]] && ! grep -q "TherapyHelpRegistry" "$therapy_file"; then + python3 - "$therapy_file" << 'PYEOF' +import sys + +filepath = sys.argv[1] +with open(filepath, 'r') as f: + content = f.read() + +old_support = ''' 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_support = ''' 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()) + }''' + +if old_support in content: + content = content.replace(old_support, new_support) + with open(filepath, 'w') as f: + f.write(content) + print("OK: supportSection replaced with TherapyHelpRegistry check") +else: + print("FAIL: supportSection pattern not found in file") + idx = content.find("private var supportSection") + if idx >= 0: + print(f" Found 'supportSection' at offset {idx}") + print(f" Context: {repr(content[idx:idx+120])}") + else: + print(" 'supportSection' not found anywhere in file!") + sys.exit(1) +PYEOF + if [[ $? -eq 0 ]]; then + success "Patched TherapySettingsView.swift for therapy help" + else + warn "Failed to patch TherapySettingsView.swift" + fi + else + info "TherapySettingsView already patched or not found — skipping" + fi + + # 4. Add the now-line indicator + attributed-subtitle support to the charts. + # Uses the SAME variable names as the L&L now_line customization, and skips any + # chart that already contains `currentTimeLayer`, so it can't double-apply or + # conflict whether L&L's now_line was applied before or after PowerPack. + python3 - << 'PYEOF' +import os + +# (a) ChartTableViewCell: attributed-subtitle setter used by the styled value labels +cell_path = "LoopKit/LoopKitUI/Views/ChartTableViewCell.swift" +if os.path.exists(cell_path): + with open(cell_path) as f: + c = f.read() + if "setSubtitleAttributedText" in c: + print("SKIP: ChartTableViewCell already has setSubtitleAttributedText") + else: + anchor = """ public func setSubtitleTextColor(color: UIColor) { + subtitleLabel?.textColor = color + }""" + addition = anchor + """ + + public func setSubtitleAttributedText(_ attributedText: NSAttributedString?) { + subtitleLabel?.attributedText = attributedText + }""" + if anchor in c: + with open(cell_path, "w") as f: + f.write(c.replace(anchor, addition, 1)) + print("OK: ChartTableViewCell attributed-subtitle setter added") + else: + print("WARN: setSubtitleTextColor anchor not found in ChartTableViewCell") +else: + print("WARN: ChartTableViewCell.swift not found") + +# (b) Now-line vertical guide on the four status charts (tinted per chart) +grid = ('let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, ' + 'yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, ' + 'axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues)') +charts = { + "LoopKit/LoopKitUI/Charts/PredictedGlucoseChart.swift": "glucoseTint", + "LoopKit/LoopKitUI/Charts/IOBChart.swift": "insulinTint", + "LoopKit/LoopKitUI/Charts/DoseChart.swift": "insulinTint", + "LoopKit/LoopKitUI/Charts/COBChart.swift": "carbTint", +} +for path, tint in charts.items(): + name = os.path.basename(path) + if not os.path.exists(path): + print("WARN: not found: " + path) + continue + with open(path) as f: + c = f.read() + if "currentTimeLayer" in c: + print("SKIP: now-line already present in " + name + " (L&L now_line or prior run)") + continue + if grid not in c: + print("WARN: gridLayer anchor not found in " + name) + continue + block = ('\n\n // Now line — vertical guide at the current time (L&L now_line customization)\n' + ' let currentTimeValue = ChartAxisValueDate(date: Date(), formatter: { _ in "" })\n' + ' let currentTimeSettings = ChartGuideLinesLayerSettings(linesColor: colors.%s, linesWidth: 1.0)\n' + ' let currentTimeLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, ' + 'yAxis: yAxisLayer.axis, settings: currentTimeSettings, axisValuesX: [currentTimeValue], ' + 'axisValuesY: [])') % tint + c = c.replace(grid, grid + block, 1) + c = c.replace(" gridLayer,\n", " gridLayer,\n currentTimeLayer,\n", 1) + with open(path, "w") as f: + f.write(c) + print("OK: now-line added to " + name) +PYEOF + if [[ $? -eq 0 ]]; then + success "Patched charts for now-line + styled value labels" + else + warn "Failed to patch charts (now-line / value labels)" + fi +} + +# ─── Phase 9: Validate & Cleanup ───────────────────────────────────────────── + +validate_installation() { + header "Phase 9: Validating installation" + + local missing=0 + + # Check a representative sample of files + local check_files=( + "Loop/Loop/Views/FoodFinder/FoodFinder_EntryPoint.swift" + "Loop/Loop/Views/LoopInsights/LoopInsights_DashboardView.swift" + "Loop/Loop/Views/AutoPresets/AutoPresets_SettingsView.swift" + "Loop/Loop/Managers/AutoPresets/AutoPresets_Coordinator.swift" + "Loop/Loop/Services/FoodFinder/FoodFinder_AIAnalysis.swift" + "Loop/Loop/Services/LoopInsights/LoopInsights_DataAggregator.swift" + "Loop/Loop/Resources/FoodFinder/FoodFinder_FeatureFlags.swift" + "Loop/Loop/Resources/LoopInsights/LoopInsights_FeatureFlags.swift" + ) + + for f in "${check_files[@]}"; do + if [[ ! -f "$f" ]]; then + warn "Missing: $f" + ((missing++)) + fi + done + + if [[ $missing -gt 0 ]]; then + warn "$missing expected files are missing" + else + success "All sample files verified" + fi + + # Verify SettingsView.swift has our insertions + local settings_file="Loop/Loop/Views/SettingsView.swift" + if grep -q "foodFinderSettingsRow" "$settings_file"; then + success "SettingsView.swift contains FoodFinder row" + else + warn "SettingsView.swift is missing FoodFinder row" + fi + + if grep -q "loopInsightsSection" "$settings_file"; then + success "SettingsView.swift contains LoopInsights section" + else + warn "SettingsView.swift is missing LoopInsights section" + fi + + if grep -q "AutoPresets_SettingsView" "$settings_file"; then + success "SettingsView.swift contains AutoPresets row" + else + warn "SettingsView.swift is missing AutoPresets row" + fi + + # Privacy purpose-string gate — HARD FAIL. A build missing any of these keys + # is rejected by TestFlight (error 90683) and silently no-ops the matching + # in-app permission prompt (e.g. AutoPresets "Allow Calendar Access" does + # nothing). This list MUST stay in sync with patch_info_plist (Phase 5b). + # Dying here turns a confusing, days-later TestFlight rejection into an + # immediate, actionable install error. + local plist="Loop/Loop/Info.plist" + PLIST_PATH="$plist" python3 - <<'PYEOF' || die "Info.plist privacy-key verification failed (see above). Re-run the installer on a clean clone; if it persists, your build flow is reverting Info.plist after install." +import os, plistlib, sys + +REQUIRED = [ + "NSPhotoLibraryUsageDescription", + "NSMicrophoneUsageDescription", + "NSSpeechRecognitionUsageDescription", + "NSLocationWhenInUseUsageDescription", + "NSLocationAlwaysAndWhenInUseUsageDescription", + "NSMotionUsageDescription", + "NSCalendarsUsageDescription", + "NSCalendarsFullAccessUsageDescription", +] + +with open(os.environ["PLIST_PATH"], "rb") as f: + data = plistlib.load(f) + +bad = [k for k in REQUIRED if not (isinstance(data.get(k), str) and data[k].strip())] +if bad: + print(" MISSING/EMPTY privacy keys: " + ", ".join(bad)) + sys.exit(1) +print(" All %d required privacy purpose strings present" % len(REQUIRED)) +PYEOF + success "Privacy purpose strings verified (TestFlight 90683 guard)" + + # Write marker file + echo "installed=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "Loop/${MARKER_FILE}" + success "Installation marker written" +} + +cleanup() { + header "Cleanup" + + pushd Loop > /dev/null + + # Remove temp remote + if git remote | grep -q "^${FEATURE_REMOTE}$"; then + git remote remove "$FEATURE_REMOTE" + success "Removed temporary remote: $FEATURE_REMOTE" + fi + + popd > /dev/null + + echo "" + echo -e "${GREEN}${BOLD}════════════════════════════════════════════════════${NC}" + echo -e "${GREEN}${BOLD} Installation Complete!${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. In Loop > Settings > Enable PowerPack Features Individually" + echo " 4. Enter your AI API key in FoodFinder Settings" + echo "" + echo -e " ${BOLD}To uninstall:${NC}" + echo " ./Scripts/install_features.sh --rollback" + echo "" +} + +# ─── Rollback ───────────────────────────────────────────────────────────────── + +rollback() { + header "Rolling back feature installation" + + if [[ ! -d "LoopWorkspace.xcworkspace" ]]; then + die "Must run from LoopWorkspace root directory." + fi + + pushd Loop > /dev/null + + # 1. Remove all new feature files + info "Removing new feature files..." + local removed=0 + for file in "${NEW_FILES[@]}"; do + if [[ -f "$file" ]]; then + rm -f "$file" + ((removed++)) + fi + done + success "Removed $removed feature files" + + # Clean up empty directories + local feature_dirs=( + "Loop/Views/FoodFinder" "Loop/Views/LoopInsights" "Loop/Views/AutoPresets" + "Loop/Views/BolusPro" "Loop/Views/SiteAtlas" "Loop/Views/DataLayer" + "Loop/Models/FoodFinder" "Loop/Models/LoopInsights" "Loop/Models/AutoPresets" + "Loop/Models/BolusPro" "Loop/Models/SiteAtlas" "Loop/Models/DataLayer" + "Loop/Services/FoodFinder" "Loop/Services/LoopInsights" + "Loop/Services/AutoPresets" "Loop/Services/BolusPro" "Loop/Services/SiteAtlas" "Loop/Services/DataLayer" + "Loop/Resources/FoodFinder" "Loop/Resources/LoopInsights/TestData" "Loop/Resources/LoopInsights" "Loop/Resources/AutoPresets" + "Loop/Resources/BolusPro" "Loop/Resources/DataLayer" + "Loop/Managers/LoopInsights" "Loop/Managers/AutoPresets" "Loop/Managers/DataLayer" + "Loop/View Models/FoodFinder" "Loop/View Models/LoopInsights" + "LoopTests/FoodFinder" "LoopTests/LoopInsights" + "Documentation/FoodFinder" "Documentation/LoopInsights" + "Documentation/AutoPresets" "Documentation/BolusPro" "Documentation/SiteAtlas" + "Documentation/DataLayer" "Documentation/GraphDetailView" + "Loop/Services" "Loop/Resources" + ) + for dir in "${feature_dirs[@]}"; do + if [[ -d "$dir" ]] && [[ -z "$(ls -A "$dir" 2>/dev/null)" ]]; then + rmdir "$dir" 2>/dev/null || true + fi + done + success "Cleaned up empty directories" + + # 2. Reset all files to HEAD state (unstages new files, restores modified files) + info "Resetting all files to HEAD..." + git reset HEAD -- . 2>/dev/null || true + git checkout HEAD -- . 2>/dev/null || true + # Remove any remaining untracked feature files + git clean -fd -- Loop/Views/FoodFinder Loop/Views/LoopInsights Loop/Views/AutoPresets \ + Loop/Models/FoodFinder Loop/Models/LoopInsights Loop/Models/AutoPresets \ + Loop/Services/FoodFinder Loop/Services/LoopInsights \ + Loop/Resources/FoodFinder Loop/Resources/LoopInsights Loop/Resources/AutoPresets \ + Loop/Managers/LoopInsights Loop/Managers/AutoPresets \ + "Loop/View Models/FoodFinder" "Loop/View Models/LoopInsights" \ + LoopTests/FoodFinder LoopTests/LoopInsights \ + Documentation/FoodFinder Documentation/LoopInsights \ + 2>/dev/null || true + success "Reset all files to HEAD" + + # 3. Remove marker + rm -f "$MARKER_FILE" + + # 4. Pop stash if one exists from our install + local stash_list + stash_list=$(git stash list 2>/dev/null || true) + if echo "$stash_list" | grep -q "pre-feature-install"; then + info "Found pre-install stash, restoring..." + git stash pop 2>/dev/null || warn "Stash pop had conflicts — resolve manually." + success "Restored pre-install state" + fi + + # 5. Remove temp remote if still present + if git remote | grep -q "^${FEATURE_REMOTE}$"; then + git remote remove "$FEATURE_REMOTE" + fi + + popd > /dev/null + + echo "" + echo -e "${GREEN}${BOLD} Rollback complete. Your Loop is back to its previous state. You must rebuild now.${NC}" + echo "" +} + +# ─── Install + Uninstall — internal entry points ───────────────────────────── + +do_install() { + validate_environment + create_backup + setup_source_remote + bump_version + install_new_files + install_body_map_assets + stamp_build_info + override_modified_files + patch_modified_files + patch_info_plist + patch_settings_view + patch_bolus_entry_viewmodel + patch_loop_data_manager + update_pbxproj + replace_app_icon + patch_loopkit + validate_installation + cleanup +} + +# ─── Splash + dispatch (shown when running in a LoopWorkspace) ─────────────── + +show_install_splash() { + clear 2>/dev/null || true + echo -e "${BOLD}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${BOLD}║ Loop (AID) PowerPack — Bundle Install ║${NC}" + echo -e "${BOLD}╚══════════════════════════════════════════════════════════╝${NC}" + echo + echo " About to install Loop (AID) PowerPack into:" + echo " $(pwd)" + echo + echo " This bundle adds these features (each is OFF by default; turn" + echo " them on individually in Loop → Settings after building):" + echo + echo " • AutoPresets — auto-activate overrides on detected motion" + echo " • BolusPro — protein/fat-aware bolusing for high-FPU meals" + echo " • FoodFinder — AI-assisted carb counting (BYO API key)" + echo " • LoopInsights — AI therapy tuning + Behavior Insights" + echo " • DataLayer — local event store with opt-in cloud upload" + echo " • GraphDetailView — long-press home chart for timestamp detail" + echo " • SiteAtlas — body-map tracker for pump/CGM rotation" + echo +} + +show_uninstall_splash() { + clear 2>/dev/null || true + echo -e "${BOLD}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${BOLD}║ Loop (AID) PowerPack — Uninstall ║${NC}" + echo -e "${BOLD}╚══════════════════════════════════════════════════════════╝${NC}" + echo + echo " About to remove every PowerPack feature from:" + echo " $(pwd)" + echo + echo " This reverts all PowerPack file additions and modifications to" + echo " Loop's source files. Your Loop install and any L&L customizations" + echo " applied before PowerPack stay untouched." + echo +} + +# Detect if PowerPack is currently installed (looks for marker file). +is_powerpack_installed() { + [[ -f "Loop/${MARKER_FILE}" ]] +} + +# Run inside a LoopWorkspace: if already installed, offer reinstall / uninstall / quit; +# otherwise show install splash + Enter-to-continue. +in_workspace_dispatch() { + if is_powerpack_installed; then + clear 2>/dev/null || true + echo -e "${BOLD}Loop (AID) PowerPack${NC}" + echo + echo " PowerPack appears to already be installed in this LoopWorkspace." + echo + echo -e " ${BOLD}1${NC}. Reinstall (uninstall first, then install fresh)" + echo -e " ${BOLD}2${NC}. Uninstall PowerPack" + echo -e " ${BOLD}Q${NC}. Quit" + echo + read -r -p " Choose [1-2 / Q]: " choice + case "$choice" in + 1) rollback && do_install ;; + 2) show_uninstall_splash + read -r -p " Type 'yes' to uninstall: " confirm + if [[ "$confirm" == "yes" ]]; then + rollback + else + echo " Aborted." + fi + ;; + Q|q|"") exit 0 ;; + *) warn "Invalid choice"; sleep 1; in_workspace_dispatch ;; + esac + else + show_install_splash + read -r -p " Press Enter to install, or Ctrl-C to cancel..." + echo + do_install + fi +} + +# ─── Bootstrap — full end-to-end flow when user is not yet in a LoopWorkspace +# +# Wraps Loop & Learn's BuildSelectScript so a single curl-piped command can +# walk the user through: Loop core install → optional L&L customizations → +# PowerPack install → final instructions to build in Xcode. +# ───────────────────────────────────────────────────────────────────────────── + +LL_BUILD_SCRIPT_URL="https://raw.githubusercontent.com/loopandlearn/lnl-scripts/main/BuildSelectScript.sh" +BUILD_LOOP_DIR="${HOME}/Downloads/BuildLoop" + +in_loopworkspace() { [[ -d "LoopWorkspace.xcworkspace" ]]; } + +find_latest_workspace() { + [[ -d "$BUILD_LOOP_DIR" ]] || return 1 + local newest + newest=$(cd "$BUILD_LOOP_DIR" && ls -dt */LoopWorkspace 2>/dev/null | head -1) + [[ -n "$newest" ]] && echo "${BUILD_LOOP_DIR}/${newest}" +} + +show_top_menu() { + clear 2>/dev/null || true + echo -e "${BOLD}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${BOLD}║ Loop (AID) PowerPack Installer ║${NC}" + echo -e "${BOLD}╚══════════════════════════════════════════════════════════╝${NC}" + echo + echo " You're not in a LoopWorkspace folder. Choose your install path:" + echo + echo -e " ${BOLD}1${NC}. Fresh install ${DIM}(recommended for new users)${NC}" + echo " → Installs Loop core, optionally applies L&L customizations," + echo " then drops you into PowerPack install." + echo + echo -e " ${BOLD}2${NC}. I already have a LoopWorkspace — show me where to run this" + echo + echo -e " ${BOLD}Q${NC}. Quit" + echo +} + +run_ll_bootstrap() { + header "Step 1 of 2: Loop core + (optional) L&L customizations" + echo + echo " Launching the Loop & Learn BuildSelectScript." + echo " • Choose ${BOLD}Build Loop${NC}, select the ${BOLD}main${NC} branch." + echo " • Apply any L&L customizations you want, or skip if you don't want any." + echo " • When the L&L script exits, this installer will resume." + echo + read -r -p " Press Enter to launch L&L BuildSelectScript..." + echo + + /bin/bash -c "$(curl -fsSL "$LL_BUILD_SCRIPT_URL")" || die "L&L BuildSelectScript failed." + + header "Step 2 of 2: PowerPack install" + local ws + ws=$(find_latest_workspace) || die "Couldn't find a fresh LoopWorkspace under ${BUILD_LOOP_DIR}. Did the L&L install succeed?" + cd "$ws" || die "Couldn't cd into $ws" + success "Found LoopWorkspace: $ws" + in_workspace_dispatch +} + +run_locate_existing() { + echo + echo " Open Terminal, cd into your LoopWorkspace folder, and re-run:" + echo + echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/LoopPowerPack/LoopWorkspace/feat/installer/Scripts/install_features.sh)\"" + echo + exit 0 +} + +bootstrap_dispatch() { + while true; do + show_top_menu + read -r -p " Choose [1-2 / Q]: " choice + case "$choice" in + 1) run_ll_bootstrap; return ;; + 2) run_locate_existing ;; + Q|q|"") exit 0 ;; + *) warn "Invalid choice: $choice"; sleep 1 ;; + esac + done +} + +# ─── Main ──────────────────────────────────────────────────────────────────── + +main() { + if in_loopworkspace; then + in_workspace_dispatch + else + bootstrap_dispatch + fi +} + +# ─── Entry Point ────────────────────────────────────────────────────────────── + +case "${1:-}" in + --rollback|--uninstall) rollback ;; + --install|--all|-y) do_install ;; + -h|--help) sed -n '2,25p' "${BASH_SOURCE[0]:-$0}" | sed 's|^# \?||'; exit 0 ;; + "") main ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; +esac diff --git a/Scripts/update_pbxproj.py b/Scripts/update_pbxproj.py new file mode 100755 index 0000000000..82896169c5 --- /dev/null +++ b/Scripts/update_pbxproj.py @@ -0,0 +1,697 @@ +#!/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_ScannedEventsView.swift", "AutoPresets_ScannedEventsView.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_SpoonacularService.swift","FoodFinder_SpoonacularService.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_RestaurantMenuView.swift","FoodFinder_RestaurantMenuView.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/LoopInsights/PowerPack_BuildInfo.swift", "PowerPack_BuildInfo.swift", "Resources/LoopInsights","loop_insights"), + ("Resources/LoopInsights/PowerPack_UpdateChecker.swift", "PowerPack_UpdateChecker.swift", "Resources/LoopInsights","loop_insights"), + ("Resources/LoopInsights/PowerPack_APIUsage.swift", "PowerPack_APIUsage.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/LoopInsights/LoopInsights_SignalGapHistoryView.swift", "LoopInsights_SignalGapHistoryView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_SubstackPromo.swift", "LoopInsights_SubstackPromo.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 _section_text(content: str, section_name: str) -> str: + """Return the body of `/* Begin section */ ... /* End section */`, + or "" if the section isn't present.""" + m = re.search( + rf'/\* Begin {section_name} section \*/(.*?)/\* End {section_name} section \*/', + content, re.DOTALL, + ) + return m.group(1) if m else "" + + +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():] + else: + print(f" WARNING: could not find PBXGroup {parent_uuid} to add child {child_name}", file=sys.stderr) + 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():] + else: + print(f" WARNING: could not find PBXSourcesBuildPhase {phase_uuid} to add entries", file=sys.stderr) + 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) + + if main_sources is None: + print(" WARNING: could not locate PBXSourcesBuildPhase for Loop target — source files won't be added to the build", file=sys.stderr) + if test_sources is None and any(t[3] in feature_ids for t in TEST_FILES): + print(" WARNING: could not locate PBXSourcesBuildPhase for LoopTests target — test files won't be added", file=sys.stderr) + + # 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] + + # Cache existing section bodies so duplicate-detection is scoped correctly. + # Critical: a FileRef UUID also appears INSIDE its BuildFile entry's + # `fileRef = ` field, so a naive `if fr not in content` check would + # produce false positives after step 1 inserts BuildFile entries and skip + # the corresponding PBXFileReference entry — leaving Xcode unable to + # resolve the type. Scope each duplicate check to the right section. + buildfile_block = _section_text(content, "PBXBuildFile") + fileref_block = _section_text(content, "PBXFileReference") + + # ── 1. PBXBuildFile entries + build_entries = [] + skipped_bf = 0 + 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 in buildfile_block: + skipped_bf += 1 + continue + build_entries.append(entry) + if build_entries: + content = content.replace( + "/* End PBXBuildFile section */", + "\n".join(build_entries) + "\n/* End PBXBuildFile section */", + ) + print(f" PBXBuildFile entries: added={len(build_entries)} skipped={skipped_bf}") + + # ── 2. PBXFileReference entries + ref_entries = [] + skipped_fr = 0 + 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 in fileref_block: + skipped_fr += 1 + continue + ref_entries.append(entry) + if ref_entries: + content = content.replace( + "/* End PBXFileReference section */", + "\n".join(ref_entries) + "\n/* End PBXFileReference section */", + ) + print(f" PBXFileReference entries: added={len(ref_entries)} skipped={skipped_fr}") + + # ── 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/TidepoolService b/TidepoolService index 4ef78bf8b5..71a0d515ee 160000 --- a/TidepoolService +++ b/TidepoolService @@ -1 +1 @@ -Subproject commit 4ef78bf8b58e2cee3e7f00fe7670fc2a7b166874 +Subproject commit 71a0d515eea1bdfca987736d9d57572a8d2c5f00 diff --git a/VersionOverride.xcconfig b/VersionOverride.xcconfig index b176650a02..022698eae4 100644 --- a/VersionOverride.xcconfig +++ b/VersionOverride.xcconfig @@ -9,4 +9,4 @@ // Version [for DIY Loop] // configure the version number in LoopWorkspace LOOP_MARKETING_VERSION = 3.14.0 -CURRENT_PROJECT_VERSION = 57 +CURRENT_PROJECT_VERSION = 58 diff --git a/version.json b/version.json new file mode 100644 index 0000000000..fe43c983fe --- /dev/null +++ b/version.json @@ -0,0 +1,5 @@ +{ + "latest": "0.3.5", + "notes": "PowerPack now runs on Loop 3.14.2. This brings in the latest upstream Loop along with Omnipod DASH support via OmnipodKit (plus Medtrum and Eversense). All your PowerPack features are unchanged. If you build it yourself (clone + Xcode), pull the latest and rebuild to move up to 3.14.2.", + "url": "https://github.com/LoopPowerPack/LoopWorkspace" +}