diff --git a/.gitmodules b/.gitmodules index 1308e60c03..bcae43aa28 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,7 @@ [submodule "Loop"] path = Loop - url = https://github.com/LoopKit/Loop.git + url = https://github.com/LoopPowerPack/Loop.git + branch = feat/AllFeatures [submodule "LoopKit"] path = LoopKit url = https://github.com/LoopKit/LoopKit.git diff --git a/AmplitudeService b/AmplitudeService index 5a7e8c69f5..fd9df8f489 160000 --- a/AmplitudeService +++ b/AmplitudeService @@ -1 +1 @@ -Subproject commit 5a7e8c69f545bd8a2347dd35f68c7ac95ec4492b +Subproject commit fd9df8f48947f2cadc2a017ab88fdae074e32d96 diff --git a/CGMBLEKit b/CGMBLEKit index ba5d0b7daf..edd8fb232e 160000 --- a/CGMBLEKit +++ b/CGMBLEKit @@ -1 +1 @@ -Subproject commit ba5d0b7daf83d282b587c8ff0e835162b8c75846 +Subproject commit edd8fb232e18a09a6c162b489172ea9d381d7bb6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..b708ff16d8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,137 @@ +# Contributing to Loop + +Thank you for your interest in contributing to Loop. + +Loop is a community effort, and contributions of all kinds are welcome. This document outlines some guidelines, good practices, and expectations for contributing to the project, with the goal of making collaboration and review as smooth as possible. + +Whether you are helping other users, improving documentation, translating the app, testing builds, reviewing code, or contributing new features and fixes, your work matters. + +Loop is built using the LoopWorkspace repository. The primary source for the app is at https://github.com/LoopKit/LoopWorkspace. + +## Ways to contribute + +There are many ways to support the Loop community: + +- **Help others** by answering questions and guiding users in support communities. +- Improve the **documentation** by updating or expanding LoopDocs. +- Improve the **app** by contributing code, fixes, features, or tests. +- Help with **translation and localization** through Loop lokalise. +- Support **testing and feedback** by validating changes and reporting issues clearly. + +### Pay it forward + +If Loop has helped you manage your diabetes successfully, consider paying it forward by helping others. Answering questions in [Loop Zulipchat](https://loop.zulipchat.com/) or the [Loop and Learn](https://www.facebook.com/groups/LOOPandLEARN) Facebook group can make a real difference for someone getting started. + +### Translate + +Loop is translated into multiple languages to make it easier to understand and use around the world. Translation for the submodules that make up the Loop app is managed through the [Loop lokalise project](https://loopkit.github.io/loopdocs/faqs/app-translation/#code-translation) and does not require programming experience. + +If your preferred language is missing, or you would like to improve an existing translation, please sign up as a translator following the directions in the link above. + +### Develop + +Do you work with Swift? UI/UX? Testing? API optimization? Data storage? + +Loop is a collaborative project, and contributions of all kinds are welcome. Whether you are writing code, improving the user experience, testing builds, helping with documentation, or contributing in other ways, your help matters. + +## General principles + +- Start small. Smaller, focused contributions are easier to review, test, and merge. +- For larger changes or new features, open or reference an issue first so there is a clear place for discussion and progress tracking. +- Reach out early if you are planning to work on something substantial, especially if it may overlap with work already in progress. +- Keep discussions constructive, respectful, and focused on improving Loop for the community. +- Remember that Loop is part of a wider open source AID ecosystem. Collaboration and maintainability matter just as much as shipping features. + +## Development guidelines + +### Coding conventions + +- Use Xcode and follow the existing formatting and style used throughout the codebase. +- Keep indentation and formatting consistent in every file you change. +- Format your code before committing. +- Avoid unrelated formatting-only changes in files you are not otherwise modifying. +- Choose clear, readable code over clever or overly compact solutions. +- Follow existing naming, file organization, and architectural patterns unless there is a good reason not to. + +### Strings and localization + +- Add new user-facing strings in the appropriate localization mechanism used by the app. +- Provide English source strings only unless the contribution is specifically about translations. +- Translation and localization for other languages should go through the [Loop lokalise project](https://loopkit.github.io/loopdocs/faqs/app-translation/#code-translation). + +### Documentation + +- Update docstrings when your change affects setup, configuration, behavior, workflows, or troubleshooting. +- Keep documentation changes clear and practical. +- ocumentation contributions are just as valuable as code contributions. + +## Branches, commits, and pull requests + +### Getting started + +The example below is for the Loop repository. Similar contributions can be made to other respositories as needed. + +1. Fork the `dev` branch of the [Loop repository](https://github.com/LoopKit/Loop) on GitHub. +1. Create a separate branch for each feature or fix with an [appropriate name](#branch-names). +1. Branch from the most recent appropriate development branch (typically `dev`). +1. Commit your changes to your fork. +1. When ready, open a pull request against the upstream repository (`LoopKit/Loop`). + +### Before opening a pull request + +- Rebase or otherwise sync your branch with the latest target branch. +- Make sure your change is focused and does not include unrelated edits. +- Test your changes as thoroughly as you reasonably can. +- Update relevant documentation when needed. +- Double-check for debug code, commented-out code, accidental version changes, or temporary workarounds left behind. + +### Pull request guidance + +- Keep pull requests as small and focused as practical. +- Use a clear title and description. +- Explain **what** changed and **why**. +- Link the relevant issue when applicable. +- Mention any areas that need particular review attention. +- Be open to feedback and follow-up changes during review. +- Use AI tools, if at all, as a support for small, well-understood tasks rather than to generate large parts of a contribution +- Do not submit AI-heavy or "vibe-coded" pull requests; we welcome thoughtful use of tooling, but contributions need to be intentionally designed. + +## Naming conventions + +### Branch names + +Use short, descriptive branch names that make the purpose of the change obvious. For example: + +- `fix/watchstate-sync` +- `feature/onboarding-target-behavior` +- `refactor/therapy-editor` + +### Pull request titles + +Use concise, descriptive pull request titles. Good titles usually start with the type of change, for example: + +- `Fix watch state sync timing issue` +- `Add onboarding step for target behavior` +- `Update build documentation` + +## Communication and coordination + +For new ideas, larger features, or work that may affect multiple parts of the app, **discuss it with the community first** — reach out to the contributor core on [Loop Zulipchat](https://loop.zulipchat.com/). This helps reduce duplicate work, avoid merge conflicts, and improve the final design. + +## Review expectations + +Please remember that Loop is maintained by contributors with limited time. Reviews may take time, and some pull requests may require iteration before they are ready to merge. + +To help keep reviews efficient: + +- Keep the scope narrow. +- Explain your reasoning clearly. +- Respond to review comments directly. +- Avoid force-pushing large unexplained rewrites during active review unless necessary. +- AI-assisted work is welcome for limited, well-understood tasks, but contributions should remain author-driven and must be code you fully understand, and can explain. + +We do not accept pull requests that are largely AI-generated or submitted without careful engineering judgment, testing, and alignment with Loop’s existing patterns. + +## Final note + +Loop exists because people choose to contribute their time, knowledge, and care to a shared effort. Thank you for helping improve the project and support the broader open source AID community. diff --git a/G7SensorKit b/G7SensorKit index 4d0780db06..890e60754d 160000 --- a/G7SensorKit +++ b/G7SensorKit @@ -1 +1 @@ -Subproject commit 4d0780db06c7c95b3a3bf3cdb2f2838d521e411a +Subproject commit 890e60754ded6b1610c8b8fac7a3c026bf704a64 diff --git a/Gemfile b/Gemfile index ce5882953b..f8b2b1e969 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,2 @@ source "https://rubygems.org" -gem "fastlane", "2.232.1" +gem "fastlane", "2.234.0" diff --git a/Gemfile.lock b/Gemfile.lock index efe764ef44..ae7d7b3417 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,7 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) @@ -68,17 +68,18 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.232.1) - CFPropertyList (>= 2.3, < 4.0.0) - abbrev (~> 0.1.2) + fastlane (2.234.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2.0) + base64 (~> 0.2) benchmark (>= 0.1.0) bundler (>= 1.17.3, < 5.0.0) colored (~> 1.2) @@ -91,7 +92,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -104,9 +105,9 @@ GEM logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) + mutex_m (~> 0.3) naturally (~> 2.2) - nkf (~> 0.2.0) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) @@ -121,8 +122,7 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -166,7 +166,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.0) + json (2.19.4) jwt (2.10.2) base64 logger (1.7.0) @@ -202,7 +202,6 @@ GEM simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -231,7 +230,7 @@ PLATFORMS ruby DEPENDENCIES - fastlane (= 2.232.1) + fastlane (= 2.234.0) BUNDLED WITH 4.0.6 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/LibreTransmitter b/LibreTransmitter index 20f6d0e171..d0d301208f 160000 --- a/LibreTransmitter +++ b/LibreTransmitter @@ -1 +1 @@ -Subproject commit 20f6d0e171450b294b202cefa8edaf2c5e4a5150 +Subproject commit d0d301208faeb2bc763454baf0550f3fd4888bb7 diff --git a/LogglyService b/LogglyService index 0a8f3c83be..d6df99ea34 160000 --- a/LogglyService +++ b/LogglyService @@ -1 +1 @@ -Subproject commit 0a8f3c83bed117248c56acf8278b18a88f399988 +Subproject commit d6df99ea34658c42eb721829d29812645c08fdad diff --git a/Loop b/Loop index 367a9878f5..40ae514ef2 160000 --- a/Loop +++ b/Loop @@ -1 +1 @@ -Subproject commit 367a9878f5274be3ad5ead4142e2837bc0c394c2 +Subproject commit 40ae514ef2cb6ee8cf0a62177de3072a460ee2e4 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 835c45a317..e7e2ee2b54 160000 --- a/LoopKit +++ b/LoopKit @@ -1 +1 @@ -Subproject commit 835c45a31789305f4e26af58405124b8a5fd45f7 +Subproject commit e7e2ee2b546c4d8122014838cb98a0e26dd91208 diff --git a/LoopOnboarding b/LoopOnboarding index 6fbc8c7ae2..64f978e143 160000 --- a/LoopOnboarding +++ b/LoopOnboarding @@ -1 +1 @@ -Subproject commit 6fbc8c7ae2594cd0931b5ea9a36b015fafcd2b13 +Subproject commit 64f978e143723765452957cef06a99db380b128c diff --git a/LoopSupport b/LoopSupport index e470d203d3..0c296289ed 160000 --- a/LoopSupport +++ b/LoopSupport @@ -1 +1 @@ -Subproject commit e470d203d386895515a058f36ddfd741da185108 +Subproject commit 0c296289ed8698cbc3acd4c1ea1b39a600c0dbc3 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/MinimedKit b/MinimedKit index ba80a8f46a..106467e8f8 160000 --- a/MinimedKit +++ b/MinimedKit @@ -1 +1 @@ -Subproject commit ba80a8f46aa6582818289e7457574017281351e6 +Subproject commit 106467e8f8effeae5a2872d121a33b548350f25c diff --git a/NightscoutRemoteCGM b/NightscoutRemoteCGM index d442e9f24f..383d3c1e6b 160000 --- a/NightscoutRemoteCGM +++ b/NightscoutRemoteCGM @@ -1 +1 @@ -Subproject commit d442e9f24f5f42cf2d5d8725809ad64084be10cf +Subproject commit 383d3c1e6b7c0c79def98a1633e4a5856bf221a4 diff --git a/NightscoutService b/NightscoutService index d6785fdcaa..7721a8da0d 160000 --- a/NightscoutService +++ b/NightscoutService @@ -1 +1 @@ -Subproject commit d6785fdcaa47fcd9efa3da19dd4be97efaedb806 +Subproject commit 7721a8da0de4f69fbc6994bdaa5c860ba9a99ede diff --git a/OmniBLE b/OmniBLE index b0b78e66a6..4e212a81aa 160000 --- a/OmniBLE +++ b/OmniBLE @@ -1 +1 @@ -Subproject commit b0b78e66a6962677970a00e5d37ae4157e548b8a +Subproject commit 4e212a81aa30e3aedeb04cec6644c39463f9db8b diff --git a/OmniKit b/OmniKit index 38af22b3d3..2b4253b9fd 160000 --- a/OmniKit +++ b/OmniKit @@ -1 +1 @@ -Subproject commit 38af22b3d36e05a4cdffb242a1a47b347a4031fc +Subproject commit 2b4253b9fd3ec167d8a6b198dae6b59606058808 diff --git a/RileyLinkKit b/RileyLinkKit index 8dad76d152..d953e1c79b 160000 --- a/RileyLinkKit +++ b/RileyLinkKit @@ -1 +1 @@ -Subproject commit 8dad76d15295e13e091be74f6f47dbca5f0eb022 +Subproject commit d953e1c79b36f06d68b7255bb8f4331d906cc30d 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..c8f9cad5ca --- /dev/null +++ b/Scripts/install_features.sh @@ -0,0 +1,1758 @@ +#!/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" + +# Version to stamp after installation +FEATURE_VERSION="3.14.0" +FEATURE_BUILD="58" + +# 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/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_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_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_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_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/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: Setting version to ${FEATURE_VERSION} (${FEATURE_BUILD})" + + if [[ -f "VersionOverride.xcconfig" ]]; then + sed -i '' "s/LOOP_MARKETING_VERSION = .*/LOOP_MARKETING_VERSION = ${FEATURE_VERSION}/" VersionOverride.xcconfig + sed -i '' "s/CURRENT_PROJECT_VERSION = .*/CURRENT_PROJECT_VERSION = ${FEATURE_BUILD}/" VersionOverride.xcconfig + success "Version set to ${FEATURE_VERSION} build ${FEATURE_BUILD}" + else + warn "VersionOverride.xcconfig not found — version not updated" + 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 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 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 +} + +# ─── 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 + + # 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 + override_modified_files + patch_modified_files + 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..84c14c407d --- /dev/null +++ b/Scripts/update_pbxproj.py @@ -0,0 +1,689 @@ +#!/usr/bin/env python3 +""" +update_pbxproj.py — Add or remove Loop (AID) PowerPack files in + Loop.xcodeproj/project.pbxproj. + +USAGE + python3 update_pbxproj.py [--features ] [--remove-features ] + + --features Comma-separated feature ids to add (default: all) + --remove-features Comma-separated feature ids to remove + + Feature ids: autopresets, bolus_pro, graph_detail_view, site_atlas, + food_finder, loop_insights + +EXAMPLES + python3 update_pbxproj.py Loop/Loop.xcodeproj/project.pbxproj + → adds every PowerPack file (back-compat: same as the old all-features run) + + python3 update_pbxproj.py --features bolus_pro Loop/Loop.xcodeproj/project.pbxproj + → adds only BolusPro files + + python3 update_pbxproj.py --remove-features bolus_pro Loop/Loop.xcodeproj/project.pbxproj + → removes only BolusPro file references / build entries + +DETERMINISTIC UUIDS + Each file/group/buildfile gets a stable md5-derived UUID so repeated + installs and remove → reinstall cycles produce identical pbxproj content. + This is what makes the per-feature removal code able to find the exact + entries it added. + +GROUP CLEANUP + Removing a feature deletes its PBXBuildFile, PBXFileReference, group + children, and PBXSourcesBuildPhase entries. Empty PBXGroup definitions + are intentionally left in place — they're harmless and reusing them on + reinstall is faster than recreating. + +Idea by Taylor Patterson. Coded by Claude Code. +Copyright © 2026 LoopKit Authors and Taylor Patterson. +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +from typing import Optional + + +# ───────────────────────────────────────────────────────────────────────────── +# Feature ids +# ───────────────────────────────────────────────────────────────────────────── + +ALL_FEATURE_IDS = ( + "autopresets", + "bolus_pro", + "graph_detail_view", + "site_atlas", + "food_finder", + "loop_insights", +) + + +def make_uuid(name: str) -> str: + """Generate a deterministic 24-char hex UUID from a name.""" + return hashlib.md5(f"FeatureInstaller_{name}".encode()).hexdigest()[:24].upper() + + +def fileref_uuid(filename: str) -> str: + return make_uuid(f"fileref_{filename}") + + +def buildfile_uuid(filename: str) -> str: + return make_uuid(f"buildfile_{filename}") + + +def group_uuid(group_key: str) -> str: + return make_uuid(f"group_{group_key}") + + +# ───────────────────────────────────────────────────────────────────────────── +# File manifest +# +# (relative_path_from_Loop/, filename, parent_group_key, feature_id) +# ───────────────────────────────────────────────────────────────────────────── + +SOURCE_FILES: list[tuple[str, str, str, str]] = [ + # ── GraphDetailView ── + ("Managers/GraphDetailViewModel.swift", "GraphDetailViewModel.swift", "Managers", "graph_detail_view"), + ("Views/GraphDetailView.swift", "GraphDetailView.swift", "Views", "graph_detail_view"), + + # ── AutoPresets — Managers ── + ("Managers/AutoPresets/AutoPresets_ActivityDetectionManager.swift", "AutoPresets_ActivityDetectionManager.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_Coordinator.swift", "AutoPresets_Coordinator.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_Delegate.swift", "AutoPresets_Delegate.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_GeofenceManager.swift", "AutoPresets_GeofenceManager.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_CalendarManager.swift", "AutoPresets_CalendarManager.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_Logger.swift", "AutoPresets_Logger.swift", "Managers/AutoPresets", "autopresets"), + ("Managers/AutoPresets/AutoPresets_Storage.swift", "AutoPresets_Storage.swift", "Managers/AutoPresets", "autopresets"), + + # ── AutoPresets — Models ── + ("Models/AutoPresets/AutoPresets_Models.swift", "AutoPresets_Models.swift", "Models/AutoPresets", "autopresets"), + ("Models/AutoPresets/AutoPresets_RecommendationModels.swift", "AutoPresets_RecommendationModels.swift", "Models/AutoPresets", "autopresets"), + + # ── AutoPresets — Services ── + ("Services/AutoPresets/AutoPresets_AIAdvisor.swift", "AutoPresets_AIAdvisor.swift", "Services/AutoPresets", "autopresets"), + + # ── AutoPresets — Resources ── + ("Resources/AutoPresets/AutoPresets_FeatureFlags.swift", "AutoPresets_FeatureFlags.swift", "Resources/AutoPresets", "autopresets"), + + # ── AutoPresets — Views ── + ("Views/AutoPresets/AutoPresets_AIRecommendationView.swift", "AutoPresets_AIRecommendationView.swift", "Views/AutoPresets", "autopresets"), + ("Views/AutoPresets/AutoPresets_GeofenceSettingsView.swift", "AutoPresets_GeofenceSettingsView.swift", "Views/AutoPresets", "autopresets"), + ("Views/AutoPresets/AutoPresets_CalendarSettingsView.swift", "AutoPresets_CalendarSettingsView.swift", "Views/AutoPresets", "autopresets"), + ("Views/AutoPresets/AutoPresets_SettingsView.swift", "AutoPresets_SettingsView.swift", "Views/AutoPresets", "autopresets"), + + # ── BolusPro ── + ("Models/BolusPro/BolusPro_Models.swift", "BolusPro_Models.swift", "Models/BolusPro", "bolus_pro"), + ("Resources/BolusPro/BolusPro_FeatureFlags.swift", "BolusPro_FeatureFlags.swift", "Resources/BolusPro", "bolus_pro"), + ("Services/BolusPro/BolusPro_FPUCalculator.swift", "BolusPro_FPUCalculator.swift", "Services/BolusPro", "bolus_pro"), + ("Services/BolusPro/BolusPro_DataLayerHook.swift", "BolusPro_DataLayerHook.swift", "Services/BolusPro", "bolus_pro"), + ("Services/BolusPro/BolusPro_BehaviorAnalyzer.swift", "BolusPro_BehaviorAnalyzer.swift", "Services/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_InfoSheet.swift", "BolusPro_InfoSheet.swift", "Views/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_OnboardingView.swift", "BolusPro_OnboardingView.swift", "Views/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_ManualMacroFields.swift", "BolusPro_ManualMacroFields.swift", "Views/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_CarbEntrySection.swift", "BolusPro_CarbEntrySection.swift", "Views/BolusPro", "bolus_pro"), + ("Views/BolusPro/BolusPro_SettingsView.swift", "BolusPro_SettingsView.swift", "Views/BolusPro", "bolus_pro"), + + # ── FoodFinder ── + ("Models/FoodFinder/FoodFinder_AnalysisRecord.swift", "FoodFinder_AnalysisRecord.swift", "Models/FoodFinder", "food_finder"), + ("Models/FoodFinder/FoodFinder_InputResults.swift", "FoodFinder_InputResults.swift", "Models/FoodFinder", "food_finder"), + ("Models/FoodFinder/FoodFinder_Models.swift", "FoodFinder_Models.swift", "Models/FoodFinder", "food_finder"), + ("Resources/FoodFinder/FoodFinder_FeatureFlags.swift", "FoodFinder_FeatureFlags.swift", "Resources/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AIAnalysis.swift", "FoodFinder_AIAnalysis.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AIProviderConfig.swift","FoodFinder_AIProviderConfig.swift","Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AIServiceAdapter.swift","FoodFinder_AIServiceAdapter.swift","Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AIServiceManager.swift","FoodFinder_AIServiceManager.swift","Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_AnalysisHistoryStore.swift","FoodFinder_AnalysisHistoryStore.swift","Services/FoodFinder","food_finder"), + ("Services/FoodFinder/FoodFinder_CarbTrackingService.swift","FoodFinder_CarbTrackingService.swift","Services/FoodFinder","food_finder"), + ("Services/FoodFinder/FoodFinder_EmojiProvider.swift", "FoodFinder_EmojiProvider.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_ImageDownloader.swift","FoodFinder_ImageDownloader.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_ImageStore.swift", "FoodFinder_ImageStore.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_LocationService.swift","FoodFinder_LocationService.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_OpenFoodFactsService.swift","FoodFinder_OpenFoodFactsService.swift","Services/FoodFinder","food_finder"), + ("Services/FoodFinder/FoodFinder_ScannerService.swift", "FoodFinder_ScannerService.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_SearchRouter.swift", "FoodFinder_SearchRouter.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_SecureStorage.swift", "FoodFinder_SecureStorage.swift", "Services/FoodFinder", "food_finder"), + ("Services/FoodFinder/FoodFinder_VoiceService.swift", "FoodFinder_VoiceService.swift", "Services/FoodFinder", "food_finder"), + ("View Models/FoodFinder/FoodFinder_SearchViewModel.swift","FoodFinder_SearchViewModel.swift","View Models/FoodFinder","food_finder"), + ("Views/FoodFinder/FoodFinder_AICameraView.swift", "FoodFinder_AICameraView.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_CarbTrackingDashboard.swift","FoodFinder_CarbTrackingDashboard.swift","Views/FoodFinder","food_finder"), + ("Views/FoodFinder/FoodFinder_EntryPoint.swift", "FoodFinder_EntryPoint.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_FavoritesHelpers.swift", "FoodFinder_FavoritesHelpers.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_ImageCropView.swift", "FoodFinder_ImageCropView.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_ScannerView.swift", "FoodFinder_ScannerView.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_SearchBar.swift", "FoodFinder_SearchBar.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_SearchResultsView.swift", "FoodFinder_SearchResultsView.swift","Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_SettingsView.swift", "FoodFinder_SettingsView.swift", "Views/FoodFinder", "food_finder"), + ("Views/FoodFinder/FoodFinder_VoiceSearchView.swift", "FoodFinder_VoiceSearchView.swift", "Views/FoodFinder", "food_finder"), + + # ── LoopInsights (includes DataLayer infrastructure) ── + ("Managers/LoopInsights/LoopInsights_BackgroundMonitor.swift", "LoopInsights_BackgroundMonitor.swift", "Managers/LoopInsights", "loop_insights"), + ("Managers/LoopInsights/LoopInsights_Coordinator.swift", "LoopInsights_Coordinator.swift", "Managers/LoopInsights", "loop_insights"), + ("Managers/DataLayer/DataLayer_Coordinator.swift", "DataLayer_Coordinator.swift", "Managers/DataLayer", "loop_insights"), + ("Models/LoopInsights/LoopInsights_Models.swift", "LoopInsights_Models.swift", "Models/LoopInsights", "loop_insights"), + ("Models/LoopInsights/LoopInsights_MFPModels.swift", "LoopInsights_MFPModels.swift", "Models/LoopInsights", "loop_insights"), + ("Models/LoopInsights/LoopInsights_Phase5Models.swift", "LoopInsights_Phase5Models.swift", "Models/LoopInsights", "loop_insights"), + ("Models/LoopInsights/LoopInsights_SuggestionRecord.swift", "LoopInsights_SuggestionRecord.swift", "Models/LoopInsights", "loop_insights"), + ("Models/LoopInsights/LoopInsights_MealDebriefModels.swift", "LoopInsights_MealDebriefModels.swift", "Models/LoopInsights", "loop_insights"), + ("Models/DataLayer/DataLayer_EventModels.swift", "DataLayer_EventModels.swift", "Models/DataLayer", "loop_insights"), + ("Models/DataLayer/DataLayer_ConsentModels.swift", "DataLayer_ConsentModels.swift", "Models/DataLayer", "loop_insights"), + ("Resources/LoopInsights/LoopInsights_FeatureFlags.swift", "LoopInsights_FeatureFlags.swift", "Resources/LoopInsights","loop_insights"), + ("Resources/DataLayer/DataLayer_FeatureFlags.swift", "DataLayer_FeatureFlags.swift", "Resources/DataLayer", "loop_insights"), + ("Services/LoopInsights/LoopInsights_AdvancedAnalyzers.swift", "LoopInsights_AdvancedAnalyzers.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_AIAnalysis.swift", "LoopInsights_AIAnalysis.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_AIServiceAdapter.swift", "LoopInsights_AIServiceAdapter.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_AlcoholTracker.swift", "LoopInsights_AlcoholTracker.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_BackfillDetector.swift", "LoopInsights_BackfillDetector.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_BehaviorInsightsAnalyzer.swift","LoopInsights_BehaviorInsightsAnalyzer.swift","Services/LoopInsights","loop_insights"), + ("Services/LoopInsights/LoopInsights_CaffeineTracker.swift", "LoopInsights_CaffeineTracker.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_CaregiverDigestService.swift","LoopInsights_CaregiverDigestService.swift","Services/LoopInsights","loop_insights"), + ("Services/LoopInsights/LoopInsights_ChatHistoryStore.swift", "LoopInsights_ChatHistoryStore.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_DataAggregator.swift", "LoopInsights_DataAggregator.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_FoodResponseAnalyzer.swift","LoopInsights_FoodResponseAnalyzer.swift","Services/LoopInsights","loop_insights"), + ("Services/LoopInsights/LoopInsights_GlucoseUnitContext.swift","LoopInsights_GlucoseUnitContext.swift","Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_GoalStore.swift", "LoopInsights_GoalStore.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_HealthKitManager.swift", "LoopInsights_HealthKitManager.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_NightscoutImporter.swift","LoopInsights_NightscoutImporter.swift","Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_ReportGenerator.swift", "LoopInsights_ReportGenerator.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_SecureStorage.swift", "LoopInsights_SecureStorage.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_SuggestionStore.swift", "LoopInsights_SuggestionStore.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_TestDataProvider.swift", "LoopInsights_TestDataProvider.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_VoiceService.swift", "LoopInsights_VoiceService.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_MealDebriefService.swift","LoopInsights_MealDebriefService.swift","Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_MFPImporter.swift", "LoopInsights_MFPImporter.swift", "Services/LoopInsights", "loop_insights"), + ("Services/LoopInsights/LoopInsights_PreMealAdvisorService.swift","LoopInsights_PreMealAdvisorService.swift","Services/LoopInsights","loop_insights"), + ("Services/DataLayer/DataLayer_SecureStorage.swift", "DataLayer_SecureStorage.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_ConsentManager.swift", "DataLayer_ConsentManager.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_EventStore.swift", "DataLayer_EventStore.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_EventCollector.swift", "DataLayer_EventCollector.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_SyncService.swift", "DataLayer_SyncService.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_ReportGenerator.swift", "DataLayer_ReportGenerator.swift", "Services/DataLayer", "loop_insights"), + ("Services/DataLayer/DataLayer_ProviderProtocol.swift", "DataLayer_ProviderProtocol.swift", "Services/DataLayer", "loop_insights"), + ("View Models/LoopInsights/LoopInsights_ChatViewModel.swift", "LoopInsights_ChatViewModel.swift", "View Models/LoopInsights","loop_insights"), + ("View Models/LoopInsights/LoopInsights_DashboardViewModel.swift","LoopInsights_DashboardViewModel.swift","View Models/LoopInsights","loop_insights"), + ("View Models/LoopInsights/LoopInsights_MealInsightsViewModel.swift","LoopInsights_MealInsightsViewModel.swift","View Models/LoopInsights","loop_insights"), + ("Views/LoopInsights/LoopInsights_AGPChartView.swift", "LoopInsights_AGPChartView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_AlcoholLogView.swift", "LoopInsights_AlcoholLogView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_BehaviorInsightsView.swift", "LoopInsights_BehaviorInsightsView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_CaregiverDigestView.swift", "LoopInsights_CaregiverDigestView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_EndoReportView.swift", "LoopInsights_EndoReportView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_ChatHistoryView.swift", "LoopInsights_ChatHistoryView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_CaffeineLogView.swift", "LoopInsights_CaffeineLogView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_ChatView.swift", "LoopInsights_ChatView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_DashboardView.swift", "LoopInsights_DashboardView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_GoalsView.swift", "LoopInsights_GoalsView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_MealInsightsView.swift", "LoopInsights_MealInsightsView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_MonitorSettingsView.swift", "LoopInsights_MonitorSettingsView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_SettingsView.swift", "LoopInsights_SettingsView.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_SuggestionDetailView.swift", "LoopInsights_SuggestionDetailView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_SuggestionHistoryView.swift","LoopInsights_SuggestionHistoryView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_TrendsInsightsView.swift", "LoopInsights_TrendsInsightsView.swift","Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_MealDebriefCard.swift", "LoopInsights_MealDebriefCard.swift", "Views/LoopInsights", "loop_insights"), + ("Views/LoopInsights/LoopInsights_PreMealAdvisorCard.swift", "LoopInsights_PreMealAdvisorCard.swift","Views/LoopInsights", "loop_insights"), + ("Views/DataLayer/DataLayer_ConsentView.swift", "DataLayer_ConsentView.swift", "Views/DataLayer", "loop_insights"), + ("Views/DataLayer/DataLayer_DashboardView.swift", "DataLayer_DashboardView.swift", "Views/DataLayer", "loop_insights"), + + # ── SiteAtlas ── + ("Models/SiteAtlas/SiteAtlas_Models.swift", "SiteAtlas_Models.swift", "Models/SiteAtlas", "site_atlas"), + ("Services/SiteAtlas/SiteAtlas_Coordinator.swift", "SiteAtlas_Coordinator.swift", "Services/SiteAtlas","site_atlas"), + ("Services/SiteAtlas/SiteAtlas_FeatureFlags.swift", "SiteAtlas_FeatureFlags.swift", "Services/SiteAtlas","site_atlas"), + ("Services/SiteAtlas/SiteAtlas_Storage.swift", "SiteAtlas_Storage.swift", "Services/SiteAtlas","site_atlas"), + ("Views/SiteAtlas/SiteAtlas_BodyMapView.swift", "SiteAtlas_BodyMapView.swift", "Views/SiteAtlas", "site_atlas"), + ("Views/SiteAtlas/SiteAtlas_SettingsView.swift", "SiteAtlas_SettingsView.swift", "Views/SiteAtlas", "site_atlas"), + ("Views/SiteAtlas/SiteAtlas_SiteSelectionSheet.swift", "SiteAtlas_SiteSelectionSheet.swift", "Views/SiteAtlas", "site_atlas"), +] + +TEST_FILES: list[tuple[str, str, str, str]] = [ + ("FoodFinder/FoodFinder_BarcodeScannerTests.swift", "FoodFinder_BarcodeScannerTests.swift", "LoopTests/FoodFinder", "food_finder"), + ("FoodFinder/FoodFinder_OpenFoodFactsTests.swift", "FoodFinder_OpenFoodFactsTests.swift", "LoopTests/FoodFinder", "food_finder"), + ("FoodFinder/FoodFinder_VoiceSearchTests.swift", "FoodFinder_VoiceSearchTests.swift", "LoopTests/FoodFinder", "food_finder"), + ("LoopInsights/LoopInsights_DataAggregatorTests.swift", "LoopInsights_DataAggregatorTests.swift", "LoopTests/LoopInsights", "loop_insights"), + ("LoopInsights/LoopInsights_ModelsTests.swift", "LoopInsights_ModelsTests.swift", "LoopTests/LoopInsights", "loop_insights"), + ("LoopInsights/LoopInsights_SuggestionStoreTests.swift", "LoopInsights_SuggestionStoreTests.swift", "LoopTests/LoopInsights", "loop_insights"), +] + +# (group_key, display_name, path, parent_group_key, owning_feature_or_None) +# owning_feature is set for feature-specific subgroups; shared parent groups +# (Services, Resources) have None. None-owned groups are never removed on +# uninstall — they persist for any other feature's use. + +SUBGROUPS: list[tuple[str, str, str, str, Optional[str]]] = [ + # Generic top-level groups under Loop (created on first use, never removed) + ("Services", "Services", "Services", "Loop", None), + ("Resources", "Resources", "Resources", "Loop", None), + + # AutoPresets feature subgroups + ("Managers/AutoPresets", "AutoPresets", "AutoPresets", "Managers", "autopresets"), + ("Models/AutoPresets", "AutoPresets", "AutoPresets", "Models", "autopresets"), + ("Services/AutoPresets", "AutoPresets", "AutoPresets", "Services", "autopresets"), + ("Resources/AutoPresets", "AutoPresets", "AutoPresets", "Resources", "autopresets"), + ("Views/AutoPresets", "AutoPresets", "AutoPresets", "Views", "autopresets"), + + # BolusPro feature subgroups + ("Models/BolusPro", "BolusPro", "BolusPro", "Models", "bolus_pro"), + ("Resources/BolusPro", "BolusPro", "BolusPro", "Resources", "bolus_pro"), + ("Services/BolusPro", "BolusPro", "BolusPro", "Services", "bolus_pro"), + ("Views/BolusPro", "BolusPro", "BolusPro", "Views", "bolus_pro"), + + # FoodFinder feature subgroups + ("Models/FoodFinder", "FoodFinder", "FoodFinder", "Models", "food_finder"), + ("Resources/FoodFinder", "FoodFinder", "FoodFinder", "Resources", "food_finder"), + ("Services/FoodFinder", "FoodFinder", "FoodFinder", "Services", "food_finder"), + ("View Models/FoodFinder", "FoodFinder", "FoodFinder", "View Models","food_finder"), + ("Views/FoodFinder", "FoodFinder", "FoodFinder", "Views", "food_finder"), + ("LoopTests/FoodFinder", "FoodFinder", "FoodFinder", "LoopTests", "food_finder"), + + # LoopInsights feature subgroups (incl. DataLayer) + ("Managers/LoopInsights", "LoopInsights", "LoopInsights", "Managers", "loop_insights"), + ("Managers/DataLayer", "DataLayer", "DataLayer", "Managers", "loop_insights"), + ("Models/LoopInsights", "LoopInsights", "LoopInsights", "Models", "loop_insights"), + ("Models/DataLayer", "DataLayer", "DataLayer", "Models", "loop_insights"), + ("Resources/LoopInsights", "LoopInsights", "LoopInsights", "Resources", "loop_insights"), + ("Resources/DataLayer", "DataLayer", "DataLayer", "Resources", "loop_insights"), + ("Services/LoopInsights", "LoopInsights", "LoopInsights", "Services", "loop_insights"), + ("Services/DataLayer", "DataLayer", "DataLayer", "Services", "loop_insights"), + ("View Models/LoopInsights","LoopInsights", "LoopInsights", "View Models","loop_insights"), + ("Views/LoopInsights", "LoopInsights", "LoopInsights", "Views", "loop_insights"), + ("Views/DataLayer", "DataLayer", "DataLayer", "Views", "loop_insights"), + ("LoopTests/LoopInsights", "LoopInsights", "LoopInsights", "LoopTests", "loop_insights"), + + # SiteAtlas feature subgroups + ("Models/SiteAtlas", "SiteAtlas", "SiteAtlas", "Models", "site_atlas"), + ("Services/SiteAtlas", "SiteAtlas", "SiteAtlas", "Services", "site_atlas"), + ("Views/SiteAtlas", "SiteAtlas", "SiteAtlas", "Views", "site_atlas"), +] + + +# ───────────────────────────────────────────────────────────────────────────── +# pbxproj parsing +# ───────────────────────────────────────────────────────────────────────────── + +def parse_all_groups(content: str) -> dict[str, dict]: + """Parse all PBXGroup definitions into a dict of uuid -> {name, path, children_uuids}.""" + section_match = re.search( + r'/\* Begin PBXGroup section \*/\n(.*?)\n/\* End PBXGroup section \*/', + content, re.DOTALL, + ) + if not section_match: + return {} + section = section_match.group(1) + groups: dict[str, dict] = {} + for m in re.finditer( + r'^\t\t([A-F0-9]{24})\s*(?:/\*[^\n]*?\*/)?\s*= \{\n(.*?)\n\t\t\};', + section, re.MULTILINE | re.DOTALL, + ): + uuid = m.group(1) + body = m.group(2) + if "isa = PBXGroup" not in body: + continue + path_m = re.search(r'path = "(.*?)";|path = ([^;"\s]+);', body) + name_m = re.search(r'name = "(.*?)";|name = ([^;"\s]+);', body) + path_val = (path_m.group(1) or path_m.group(2)) if path_m else None + name_val = (name_m.group(1) or name_m.group(2)) if name_m else None + display = name_val or path_val or "unknown" + children = [] + children_m = re.search(r'children = \(\n(.*?)\n\t\t\t\);', body, re.DOTALL) + if children_m: + for c in re.finditer(r'([A-F0-9]{24})', children_m.group(1)): + children.append(c.group(1)) + groups[uuid] = {"name": display, "path": path_val, "children": children} + return groups + + +def find_groups_by_hierarchy(content: str) -> dict[str, str]: + """Walk PBXProject → mainGroup → Loop. Returns {logical_name: uuid}.""" + all_groups = parse_all_groups(content) + main_group_match = re.search(r'mainGroup = ([A-F0-9]{24})', content) + if not main_group_match: + return {} + main_group_uuid = main_group_match.group(1) + main_group = all_groups.get(main_group_uuid, {}) + result: dict[str, str] = {} + for child_uuid in main_group.get("children", []): + child = all_groups.get(child_uuid, {}) + child_path = child.get("path") + child_name = child.get("name") + if child_path == "Loop" or child_name == "Loop": + result["Loop"] = child_uuid + elif child_path == "LoopTests" or child_name == "LoopTests": + result["LoopTests"] = child_uuid + loop_uuid = result.get("Loop") + if loop_uuid and loop_uuid in all_groups: + for child_uuid in all_groups[loop_uuid]["children"]: + child = all_groups.get(child_uuid, {}) + display = child.get("name") or child.get("path") + if display in ("Views", "Models", "View Models", "Managers", "Services", "Resources"): + result[display] = child_uuid + return result + + +def find_main_sources_phase(content: str) -> Optional[str]: + target_section = re.search( + r'/\* Begin PBXNativeTarget section \*/\n(.*?)\n/\* End PBXNativeTarget section \*/', + content, re.DOTALL, + ) + if not target_section: + return None + for m in re.finditer( + r'([A-F0-9]{24}) /\* (Loop[^*]*?)\*/ = \{(.*?)\n\t\t\};', + target_section.group(1), re.DOTALL, + ): + target_name = m.group(2).strip() + if target_name == "Loop": + phases_match = re.search(r'buildPhases = \(\n(.*?)\n\t\t\t\);', m.group(3), re.DOTALL) + if phases_match: + sources_match = re.search(r'([A-F0-9]{24}) /\*[^\n]*?Sources[^\n]*?\*/', phases_match.group(1)) + if sources_match: + return sources_match.group(1) + return None + + +def find_test_sources_phase(content: str) -> Optional[str]: + target_section = re.search( + r'/\* Begin PBXNativeTarget section \*/\n(.*?)\n/\* End PBXNativeTarget section \*/', + content, re.DOTALL, + ) + if not target_section: + return None + for m in re.finditer( + r'([A-F0-9]{24}) /\* (LoopTests[^*]*?)\*/ = \{(.*?)\n\t\t\};', + target_section.group(1), re.DOTALL, + ): + target_name = m.group(2).strip() + if target_name == "LoopTests": + phases_match = re.search(r'buildPhases = \(\n(.*?)\n\t\t\t\);', m.group(3), re.DOTALL) + if phases_match: + sources_match = re.search(r'([A-F0-9]{24}) /\*[^\n]*?Sources[^\n]*?\*/', phases_match.group(1)) + if sources_match: + return sources_match.group(1) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# pbxproj mutation: ADD +# ───────────────────────────────────────────────────────────────────────────── + +def _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 5f4927dcac..4ef78bf8b5 160000 --- a/TidepoolService +++ b/TidepoolService @@ -1 +1 @@ -Subproject commit 5f4927dcac2b17276776b83016896997001b1a67 +Subproject commit 4ef78bf8b58e2cee3e7f00fe7670fc2a7b166874 diff --git a/VersionOverride.xcconfig b/VersionOverride.xcconfig index c2a8a4bf6f..022698eae4 100644 --- a/VersionOverride.xcconfig +++ b/VersionOverride.xcconfig @@ -8,5 +8,5 @@ // Version [for DIY Loop] // configure the version number in LoopWorkspace -LOOP_MARKETING_VERSION = 3.12.1 -CURRENT_PROJECT_VERSION = 57 +LOOP_MARKETING_VERSION = 3.14.0 +CURRENT_PROJECT_VERSION = 58 diff --git a/dexcom-share-client-swift b/dexcom-share-client-swift index 875faf232b..04804892ea 160000 --- a/dexcom-share-client-swift +++ b/dexcom-share-client-swift @@ -1 +1 @@ -Subproject commit 875faf232bb3f13d619512f9e8b47a2d5acac433 +Subproject commit 04804892ea58778472e19c738ae39a87f41c0070