From 0c405ebdf4b7f16c05ba37f4d8f755f119d0efe7 Mon Sep 17 00:00:00 2001 From: rinaus2004-droid Date: Sat, 18 Oct 2025 19:52:07 +0000 Subject: [PATCH 1/3] Add CI and TestFlight workflows for Octreotide feature --- .github/workflows/octreotide-ci.yml | 56 +++++ .github/workflows/testflight-upload.yml | 77 +++++++ .../Octreotide/GlucoseSimulator.swift | 53 +++++ .../Octreotide/OctreotideAlgorithm.swift | 206 ++++++++++++++++++ Experimental/Octreotide/README.md | 84 +++++++ .../Tests/OctreotideAlgorithmTests.swift | 132 +++++++++++ .../Views/OctreotideSettingsView.swift | 127 +++++++++++ .../Views/OctreotideSimulatorView.swift | 136 ++++++++++++ 8 files changed, 871 insertions(+) create mode 100644 .github/workflows/octreotide-ci.yml create mode 100644 .github/workflows/testflight-upload.yml create mode 100644 Experimental/Octreotide/GlucoseSimulator.swift create mode 100644 Experimental/Octreotide/OctreotideAlgorithm.swift create mode 100644 Experimental/Octreotide/README.md create mode 100644 Experimental/Octreotide/Tests/OctreotideAlgorithmTests.swift create mode 100644 Experimental/Octreotide/Views/OctreotideSettingsView.swift create mode 100644 Experimental/Octreotide/Views/OctreotideSimulatorView.swift diff --git a/.github/workflows/octreotide-ci.yml b/.github/workflows/octreotide-ci.yml new file mode 100644 index 0000000000..1bfcc73c85 --- /dev/null +++ b/.github/workflows/octreotide-ci.yml @@ -0,0 +1,56 @@ +name: Octreotide CI + +on: + push: + branches: [ feature/octreotide-algorithm ] + paths: + - 'Experimental/Octreotide/**' + pull_request: + branches: [ main ] + paths: + - 'Experimental/Octreotide/**' + workflow_dispatch: # Allow manual triggers + +jobs: + test: + name: Run Tests + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: List Available Schemes + run: xcodebuild -workspace LoopWorkspace.xcworkspace -list + + - name: Build and Test + run: | + xcodebuild test \ + -workspace LoopWorkspace.xcworkspace \ + -scheme LoopWorkspace \ + -destination 'platform=iOS Simulator,name=iPhone 14,OS=latest' \ + -enableCodeCoverage YES \ + -resultBundlePath TestResults.xcresult + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-results + path: TestResults.xcresult + + - name: Generate Coverage Report + if: success() + run: | + xcrun xccov view --report TestResults.xcresult > coverage.txt + + - name: Upload Coverage + if: success() + uses: actions/upload-artifact@v3 + with: + name: code-coverage + path: coverage.txt \ No newline at end of file diff --git a/.github/workflows/testflight-upload.yml b/.github/workflows/testflight-upload.yml new file mode 100644 index 0000000000..c5661daf74 --- /dev/null +++ b/.github/workflows/testflight-upload.yml @@ -0,0 +1,77 @@ +name: Build and Upload to TestFlight + +on: + workflow_dispatch: # Manual trigger only for safety + inputs: + build_type: + description: 'Build Type' + required: true + default: 'development' + type: choice + options: + - development + - release + +jobs: + build-and-upload: + name: Build and Upload to TestFlight + runs-on: macos-latest + + # Only run if required secrets are available + if: | + secrets.TEAMID != '' && + secrets.FASTLANE_KEY_ID != '' && + secrets.FASTLANE_ISSUER_ID != '' && + secrets.FASTLANE_KEY != '' + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Install Fastlane + run: | + gem install bundler + bundle install + + - name: Setup Provisioning + env: + TEAMID: ${{ secrets.TEAMID }} + FASTLANE_KEY_ID: ${{ secrets.FASTLANE_KEY_ID }} + FASTLANE_ISSUER_ID: ${{ secrets.FASTLANE_ISSUER_ID }} + FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }} + GH_PAT: ${{ secrets.GH_PAT }} + run: | + bundle exec fastlane setup + + - name: Build and Upload + env: + TEAMID: ${{ secrets.TEAMID }} + FASTLANE_KEY_ID: ${{ secrets.FASTLANE_KEY_ID }} + FASTLANE_ISSUER_ID: ${{ secrets.FASTLANE_ISSUER_ID }} + FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }} + BUILD_TYPE: ${{ github.event.inputs.build_type }} + run: | + if [ "$BUILD_TYPE" = "release" ]; then + bundle exec fastlane release + else + bundle exec fastlane beta + fi + + - name: Upload IPA + uses: actions/upload-artifact@v3 + with: + name: loop-ipa + path: | + Loop.ipa + ExportOptions.plist + + - name: Upload Build Logs + if: always() + uses: actions/upload-artifact@v3 + with: + name: build-logs + path: buildlog/ \ No newline at end of file diff --git a/Experimental/Octreotide/GlucoseSimulator.swift b/Experimental/Octreotide/GlucoseSimulator.swift new file mode 100644 index 0000000000..16c3fa36fc --- /dev/null +++ b/Experimental/Octreotide/GlucoseSimulator.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Simulates glucose patterns for testing the Octreotide algorithm +struct GlucoseSimulator { + // Base glucose level (mg/dL) + private var baseLevel: Double + // Current trend direction (-1 to 1) + private var trendDirection: Double + // Noise amplitude (mg/dL) + private var noiseAmplitude: Double + + init(baseLevel: Double = 80.0, trendDirection: Double = 0.0, noiseAmplitude: Double = 2.0) { + self.baseLevel = baseLevel + self.trendDirection = trendDirection + self.noiseAmplitude = noiseAmplitude + } + + /// Generate simulated glucose values + /// - Parameters: + /// - count: Number of samples to generate + /// - intervalMinutes: Minutes between samples + func generateSamples(count: Int, intervalMinutes: Double = 5.0) -> [Double] { + var samples: [Double] = [] + var currentLevel = baseLevel + + for _ in 0.. GlucoseSimulator { + return GlucoseSimulator(baseLevel: startLevel, trendDirection: -0.5) + } + + /// Generate a rising glucose pattern + static func risingPattern(from startLevel: Double = 70.0) -> GlucoseSimulator { + return GlucoseSimulator(baseLevel: startLevel, trendDirection: 0.5) + } +} \ No newline at end of file diff --git a/Experimental/Octreotide/OctreotideAlgorithm.swift b/Experimental/Octreotide/OctreotideAlgorithm.swift new file mode 100644 index 0000000000..1031cb3a79 --- /dev/null +++ b/Experimental/Octreotide/OctreotideAlgorithm.swift @@ -0,0 +1,206 @@ +// OctreotideAlgorithm.swift +// Reverse-Logic control module for Octreotide infusion +// Designed as a drop-in module for Loop (developer preview, simulation only). +// IMPORTANT: This is a clinical experiment template. Do NOT run closed-loop on a patient without +// rigorous simulation, clinical oversight, and regulatory/safety approval. + +import Foundation + +/// Represents a recommended change in medication delivery +struct DeliveryRecommendation { + let basalRate: Double // U/hr + let bolus: Double // U + let messages: [String] // User/clinician messages + let trend: Double // mg/dL/min + let safetyFlags: Set + + enum SafetyFlag: String { + case insufficientHistory = "Insufficient glucose history" + case rapidChange = "Rapid glucose change detected" + case bolusCap = "Daily bolus cap reached" + case bolusDelay = "Minimum time between boluses not met" + case criticalLow = "Critical low glucose" + case pdParamsInvalid = "PD parameters invalid or missing" + } +} + +/// Algorithm for Octreotide (somatostatin analog) delivery based on CGM trend +struct OctreotideAlgorithm { + // MARK: - Tunable parameters (clinician-adjustable) + + /// Glucose target range (mg/dL) + var targetLow: Double = 70.0 + var targetHigh: Double = 100.0 + var criticalLow: Double = 65.0 + + /// Basal delivery limits (U/hr) + var minBasal: Double = 0.25 + var maxBasal: Double = 5.0 + + /// Basal multipliers for different glucose ranges + var basalMultLow: Double = 1.5 // Multiply scheduled basal by this when low + var basalMultHigh: Double = 0.5 // Multiply by this when high/rising + + /// Trend thresholds (mg/dL per min) - negative is falling + var trendFallFast: Double = -1.0 + var trendRiseFast: Double = 1.0 + + /// Bolus configuration + var bolusUnit: Double = 1.0 // Standard bolus size (U) + var bolusRepeatDelay: TimeInterval = 15 * 60 // Min seconds between boluses + var maxDailyBolus: Double = 30.0 // Maximum total bolus units per day + + /// Pharmacodynamic parameters (required for safety) + var pdOnsetMinutes: Double = 30.0 // Time to start of action + var pdPeakMinutes: Double = 90.0 // Time to peak action + var pdDurationMinutes: Double = 240.0 // Total duration of action + + // MARK: - Utility + + private func clamp(_ value: Double, _ minVal: Double, _ maxVal: Double) -> Double { + return max(minVal, min(value, maxVal)) + } + + /// Compute trend in mg/dL per minute using 3 samples + /// - Parameter glucoseHistory: Ordered oldest to newest, ~5min spacing + private func computeTrendRate(glucoseHistory: [Double]) -> Double { + guard glucoseHistory.count >= 3 else { return 0.0 } + let last = glucoseHistory[glucoseHistory.count - 1] + let thirdLast = glucoseHistory[glucoseHistory.count - 3] + // Approximate over ~10 minutes + return (last - thirdLast) / 10.0 + } + + /// Validate PD parameters are present and reasonable + private func validatePDParams() -> Bool { + guard pdOnsetMinutes > 0, + pdPeakMinutes > pdOnsetMinutes, + pdDurationMinutes > pdPeakMinutes else { + return false + } + return true + } + + /// Main recommendation function + /// - Parameters: + /// - glucoseNow: Current glucose in mg/dL + /// - glucoseHistory: Recent glucose samples (mg/dL), oldest->newest, ~5min spacing + /// - scheduledBasal: Current scheduled basal rate (U/hr) + /// - lastBolusDate: Time of last bolus (if any) + /// - dailyBolusTotal: Total bolus units given today + /// - Returns: Recommended basal rate, bolus amount, and status messages + func computeRecommendation( + glucoseNow: Double, + glucoseHistory: [Double], + scheduledBasal: Double, + lastBolusDate: Date?, + dailyBolusTotal: Double + ) -> DeliveryRecommendation { + // Start with current basal as baseline + var recommendedBasal = scheduledBasal + var recommendedBolus: Double = 0.0 + var messages: [String] = [] + var safetyFlags: Set = [] + + // Compute trend (mg/dL/min) + let trend = computeTrendRate(glucoseHistory: glucoseHistory) + + // MARK: Safety Checks + + // 1. Check glucose history + guard glucoseHistory.count >= 3 else { + safetyFlags.insert(.insufficientHistory) + return DeliveryRecommendation( + basalRate: minBasal, + bolus: 0, + messages: ["Insufficient glucose history for safe automation"], + trend: 0, + safetyFlags: safetyFlags + ) + } + + // 2. Validate PD parameters + guard validatePDParams() else { + safetyFlags.insert(.pdParamsInvalid) + return DeliveryRecommendation( + basalRate: minBasal, + bolus: 0, + messages: ["Invalid pharmacodynamic parameters - check configuration"], + trend: trend, + safetyFlags: safetyFlags + ) + } + + // 3. Check for rapid changes + if abs(trend) > max(abs(trendFallFast), abs(trendRiseFast)) { + safetyFlags.insert(.rapidChange) + messages.append("Rapid glucose change detected") + } + + // 4. Check critical low + if glucoseNow <= criticalLow { + safetyFlags.insert(.criticalLow) + // Max out basal but no bolus + recommendedBasal = maxBasal + messages.append("Critical low - maximizing basal delivery") + return DeliveryRecommendation( + basalRate: recommendedBasal, + bolus: 0, + messages: messages, + trend: trend, + safetyFlags: safetyFlags + ) + } + + // MARK: Basal Adjustments + + // Adjust basal rate based on current glucose and trend + if glucoseNow < targetLow || trend < trendFallFast { + // Low or falling fast - increase basal + recommendedBasal = scheduledBasal * basalMultLow + messages.append("Increasing basal due to low/falling glucose") + } else if glucoseNow > targetHigh || trend > trendRiseFast { + // High or rising fast - decrease basal + recommendedBasal = scheduledBasal * basalMultHigh + messages.append("Decreasing basal due to high/rising glucose") + } + + // Clamp basal rate to limits + recommendedBasal = clamp(recommendedBasal, minBasal, maxBasal) + + // MARK: Bolus Logic + + // Helper to check bolus eligibility + func canGiveBolus() -> Bool { + if dailyBolusTotal + bolusUnit > maxDailyBolus { + safetyFlags.insert(.bolusCap) + return false + } + if let last = lastBolusDate { + if Date().timeIntervalSince(last) < bolusRepeatDelay { + safetyFlags.insert(.bolusDelay) + return false + } + } + return true + } + + // Consider bolus for significant lows or rapid drops + if (glucoseNow < targetLow && trend < 0) || trend < trendFallFast { + if canGiveBolus() { + recommendedBolus = bolusUnit + messages.append("Recommending bolus for low/falling glucose") + } else { + messages.append("Bolus indicated but safety cap prevents delivery") + } + } + + return DeliveryRecommendation( + basalRate: recommendedBasal, + bolus: recommendedBolus, + messages: messages, + trend: trend, + safetyFlags: safetyFlags + ) + } +} \ No newline at end of file diff --git a/Experimental/Octreotide/README.md b/Experimental/Octreotide/README.md new file mode 100644 index 0000000000..a813052917 --- /dev/null +++ b/Experimental/Octreotide/README.md @@ -0,0 +1,84 @@ +# Octreotide Algorithm Module + +**IMPORTANT: Clinical Research Use Only** +This module is a prototype for research and development. It is not approved for patient use. +All implementation must be done under clinical supervision with appropriate safety protocols. + +## Overview + +This module provides a reverse-logic control algorithm for Octreotide (somatostatin analog) delivery via insulin pump hardware. It is designed for patients with endogenous hyperinsulinism under clinical supervision. + +Key features: +- Inverted glucose response (increase delivery for low/falling glucose) +- Configurable pharmacodynamic (PD) parameters +- Multiple safety checks and limits +- Comprehensive test coverage + +## Integration Steps + +1. Add to Loop Project: + - Copy `OctreotideAlgorithm.swift` to `Loop/Loop/Algorithms/` or appropriate algorithm directory + - Copy tests to `Loop/LoopTests/Algorithms/` + - Add files to appropriate Xcode targets + +2. Wire into LoopManager: + ```swift + class LoopManager { + private var octreotideAlgorithm: OctreotideAlgorithm? + + func initializeOctreotideMode(enabled: Bool) { + octreotideAlgorithm = enabled ? OctreotideAlgorithm() : nil + // Configure PD params from settings + } + + func getRecommendation() -> DeliveryRecommendation { + if let algorithm = octreotideAlgorithm { + return algorithm.computeRecommendation( + glucoseNow: currentGlucose, + glucoseHistory: recentGlucose, + scheduledBasal: scheduledBasalRate, + lastBolusDate: lastBolusDate, + dailyBolusTotal: totalDailyBolus + ) + } + // ... normal insulin logic + } + } + ``` + +3. Add Settings UI: + - Toggle in Advanced Settings + - PD parameter configuration + - Safety warnings and confirmations + +4. Testing: + - Run unit tests + - Simulation mode testing + - Clinician review of outputs + +## Safety Notes + +1. PD Parameter Validation + - Must have valid onset, peak, duration + - Should be configured by clinician + +2. Multiple Safety Checks + - Insufficient CGM data + - Maximum daily bolus + - Minimum time between boluses + - Critical low detection + +3. Required Clinical Setup + - Must be enabled by clinician + - Requires acceptance of warnings + - Should run in simulation/open-loop first + +## Development Status + +- [x] Core algorithm implementation +- [x] Safety checks and limits +- [x] Unit tests +- [ ] Integration with Loop +- [ ] Settings UI +- [ ] Clinical validation +- [ ] Documentation \ No newline at end of file diff --git a/Experimental/Octreotide/Tests/OctreotideAlgorithmTests.swift b/Experimental/Octreotide/Tests/OctreotideAlgorithmTests.swift new file mode 100644 index 0000000000..345766f45d --- /dev/null +++ b/Experimental/Octreotide/Tests/OctreotideAlgorithmTests.swift @@ -0,0 +1,132 @@ +import XCTest +@testable import LoopKit + +final class OctreotideAlgorithmTests: XCTestCase { + var algorithm: OctreotideAlgorithm! + + override func setUp() { + super.setUp() + algorithm = OctreotideAlgorithm() + } + + func testSafetyChecks() { + // Test insufficient history + let rec1 = algorithm.computeRecommendation( + glucoseNow: 80, + glucoseHistory: [80, 82], // Only 2 samples + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + XCTAssertTrue(rec1.safetyFlags.contains(.insufficientHistory)) + XCTAssertEqual(rec1.basalRate, algorithm.minBasal) + XCTAssertEqual(rec1.bolus, 0) + + // Test critical low + let rec2 = algorithm.computeRecommendation( + glucoseNow: algorithm.criticalLow - 1, + glucoseHistory: [70, 67, 64], + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + XCTAssertTrue(rec2.safetyFlags.contains(.criticalLow)) + XCTAssertEqual(rec2.basalRate, algorithm.maxBasal) + XCTAssertEqual(rec2.bolus, 0) + } + + func testTrendComputation() { + // Stable glucose + let rec1 = algorithm.computeRecommendation( + glucoseNow: 85, + glucoseHistory: [85, 85, 85], + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + XCTAssertEqual(rec1.trend, 0, accuracy: 0.01) + + // Falling glucose (-2 mg/dL/min) + let rec2 = algorithm.computeRecommendation( + glucoseNow: 80, + glucoseHistory: [100, 90, 80], + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + XCTAssertEqual(rec2.trend, -2.0, accuracy: 0.01) + XCTAssertTrue(rec2.basalRate > 1.0) // Should increase basal + } + + func testBolusLimits() { + // Test daily max + let rec1 = algorithm.computeRecommendation( + glucoseNow: 65, + glucoseHistory: [75, 70, 65], + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: algorithm.maxDailyBolus + ) + XCTAssertTrue(rec1.safetyFlags.contains(.bolusCap)) + XCTAssertEqual(rec1.bolus, 0) + + // Test minimum delay + let rec2 = algorithm.computeRecommendation( + glucoseNow: 65, + glucoseHistory: [75, 70, 65], + scheduledBasal: 1.0, + lastBolusDate: Date(), + dailyBolusTotal: 0 + ) + XCTAssertTrue(rec2.safetyFlags.contains(.bolusDelay)) + XCTAssertEqual(rec2.bolus, 0) + } + + func testBasalAdjustments() { + // Low glucose should increase basal + let rec1 = algorithm.computeRecommendation( + glucoseNow: algorithm.targetLow - 1, + glucoseHistory: [70, 69, 68], + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + XCTAssertEqual(rec1.basalRate, 1.0 * algorithm.basalMultLow) + + // High glucose should decrease basal + let rec2 = algorithm.computeRecommendation( + glucoseNow: algorithm.targetHigh + 1, + glucoseHistory: [95, 98, 101], + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + XCTAssertEqual(rec2.basalRate, 1.0 * algorithm.basalMultHigh) + } + + func testPDValidation() { + // Invalid PD params + algorithm.pdOnsetMinutes = 0 + let rec1 = algorithm.computeRecommendation( + glucoseNow: 80, + glucoseHistory: [80, 80, 80], + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + XCTAssertTrue(rec1.safetyFlags.contains(.pdParamsInvalid)) + + // Valid PD params + algorithm.pdOnsetMinutes = 30 + algorithm.pdPeakMinutes = 90 + algorithm.pdDurationMinutes = 240 + let rec2 = algorithm.computeRecommendation( + glucoseNow: 80, + glucoseHistory: [80, 80, 80], + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + XCTAssertFalse(rec2.safetyFlags.contains(.pdParamsInvalid)) + } +} \ No newline at end of file diff --git a/Experimental/Octreotide/Views/OctreotideSettingsView.swift b/Experimental/Octreotide/Views/OctreotideSettingsView.swift new file mode 100644 index 0000000000..d38376e0e4 --- /dev/null +++ b/Experimental/Octreotide/Views/OctreotideSettingsView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import LoopKit + +struct OctreotideSettingsView: View { + @Binding var isEnabled: Bool + @Binding var algorithm: OctreotideAlgorithm + @State private var showingWarning = false + @State private var clinicianConfirmed = false + + var body: some View { + Form { + Section(header: Text("Octreotide Mode")) { + Toggle("Enable Octreotide Mode", isOn: $isEnabled) + .onChange(of: isEnabled) { newValue in + if newValue { + showingWarning = true + } + } + } + .alert("Important Safety Warning", isPresented: $showingWarning) { + Button("Cancel") { + isEnabled = false + } + Button("I Understand") { + clinicianConfirmed = true + } + } message: { + Text("This mode is for clinical research use only. It must be configured by a healthcare provider familiar with octreotide therapy. Incorrect settings can cause severe hypoglycemia or hyperglycemia.") + } + + if isEnabled { + Section(header: Text("Glucose Targets")) { + HStack { + Text("Target Range") + Spacer() + TextField("Low", value: $algorithm.targetLow, format: .number) + .multilineTextAlignment(.trailing) + Text("-") + TextField("High", value: $algorithm.targetHigh, format: .number) + .multilineTextAlignment(.trailing) + Text("mg/dL") + } + HStack { + Text("Critical Low") + Spacer() + TextField("Value", value: $algorithm.criticalLow, format: .number) + .multilineTextAlignment(.trailing) + Text("mg/dL") + } + } + + Section(header: Text("Basal Settings")) { + HStack { + Text("Minimum Basal") + Spacer() + TextField("Value", value: $algorithm.minBasal, format: .number) + .multilineTextAlignment(.trailing) + Text("U/hr") + } + HStack { + Text("Maximum Basal") + Spacer() + TextField("Value", value: $algorithm.maxBasal, format: .number) + .multilineTextAlignment(.trailing) + Text("U/hr") + } + } + + Section(header: Text("Bolus Settings")) { + HStack { + Text("Standard Bolus") + Spacer() + TextField("Value", value: $algorithm.bolusUnit, format: .number) + .multilineTextAlignment(.trailing) + Text("U") + } + HStack { + Text("Daily Maximum") + Spacer() + TextField("Value", value: $algorithm.maxDailyBolus, format: .number) + .multilineTextAlignment(.trailing) + Text("U") + } + HStack { + Text("Minimum Interval") + Spacer() + TextField("Value", value: .constant(algorithm.bolusRepeatDelay / 60), format: .number) + .multilineTextAlignment(.trailing) + Text("min") + } + } + + Section(header: Text("Pharmacodynamics")) { + HStack { + Text("Onset Time") + Spacer() + TextField("Value", value: $algorithm.pdOnsetMinutes, format: .number) + .multilineTextAlignment(.trailing) + Text("min") + } + HStack { + Text("Peak Time") + Spacer() + TextField("Value", value: $algorithm.pdPeakMinutes, format: .number) + .multilineTextAlignment(.trailing) + Text("min") + } + HStack { + Text("Duration") + Spacer() + TextField("Value", value: $algorithm.pdDurationMinutes, format: .number) + .multilineTextAlignment(.trailing) + Text("min") + } + } + + if !clinicianConfirmed { + Section { + Text("⚠️ Settings must be reviewed by your healthcare provider") + .foregroundColor(.orange) + } + } + } + } + .navigationTitle("Octreotide Settings") + } +} \ No newline at end of file diff --git a/Experimental/Octreotide/Views/OctreotideSimulatorView.swift b/Experimental/Octreotide/Views/OctreotideSimulatorView.swift new file mode 100644 index 0000000000..820a38f0a6 --- /dev/null +++ b/Experimental/Octreotide/Views/OctreotideSimulatorView.swift @@ -0,0 +1,136 @@ +import SwiftUI +import Charts + +struct OctreotideSimulatorView: View { + @State private var algorithm = OctreotideAlgorithm() + @State private var glucoseValues: [Double] = [] + @State private var recommendations: [DeliveryRecommendation] = [] + @State private var simulationMode: String = "stable" + @State private var isSimulating = false + + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + VStack { + Picker("Simulation Mode", selection: $simulationMode) { + Text("Stable").tag("stable") + Text("Falling").tag("falling") + Text("Rising").tag("rising") + } + .pickerStyle(.segmented) + .padding() + + if !glucoseValues.isEmpty { + Chart { + ForEach(Array(glucoseValues.enumerated()), id: \.offset) { index, value in + LineMark( + x: .value("Time", index), + y: .value("Glucose", value) + ) + } + } + .frame(height: 200) + .padding() + } + + if let latest = recommendations.last { + VStack(alignment: .leading) { + Text("Latest Recommendation:") + .font(.headline) + Text("Basal Rate: \(latest.basalRate, specifier: "%.2f") U/hr") + Text("Bolus: \(latest.bolus, specifier: "%.2f") U") + Text("Trend: \(latest.trend, specifier: "%.2f") mg/dL/min") + if !latest.messages.isEmpty { + Text("Messages:") + ForEach(latest.messages, id: \.self) { message in + Text("• \(message)") + .foregroundColor(.secondary) + } + } + if !latest.safetyFlags.isEmpty { + Text("Safety Flags:") + .foregroundColor(.red) + ForEach(Array(latest.safetyFlags), id: \.rawValue) { flag in + Text("⚠️ \(flag.rawValue)") + .foregroundColor(.red) + } + } + } + .padding() + } + + Button(isSimulating ? "Stop Simulation" : "Start Simulation") { + isSimulating.toggle() + if isSimulating { + startSimulation() + } + } + .buttonStyle(.borderedProminent) + .padding() + } + .onReceive(timer) { _ in + if isSimulating { + updateSimulation() + } + } + } + + private func startSimulation() { + glucoseValues = [] + recommendations = [] + + let simulator: GlucoseSimulator + switch simulationMode { + case "falling": + simulator = .fallingPattern() + case "rising": + simulator = .risingPattern() + default: + simulator = GlucoseSimulator() + } + + glucoseValues = simulator.generateSamples(count: 3) + updateRecommendation() + } + + private func updateSimulation() { + let simulator: GlucoseSimulator + switch simulationMode { + case "falling": + simulator = .fallingPattern(from: glucoseValues.last ?? 90.0) + case "rising": + simulator = .risingPattern(from: glucoseValues.last ?? 70.0) + default: + simulator = GlucoseSimulator(baseLevel: glucoseValues.last ?? 80.0) + } + + let newValue = simulator.generateSamples(count: 1)[0] + glucoseValues.append(newValue) + + // Keep last 12 values (1 hour at 5-min intervals) + if glucoseValues.count > 12 { + glucoseValues.removeFirst() + } + + updateRecommendation() + } + + private func updateRecommendation() { + guard glucoseValues.count >= 3 else { return } + + let recommendation = algorithm.computeRecommendation( + glucoseNow: glucoseValues.last!, + glucoseHistory: glucoseValues, + scheduledBasal: 1.0, + lastBolusDate: nil, + dailyBolusTotal: 0 + ) + + recommendations.append(recommendation) + + // Keep last 12 recommendations + if recommendations.count > 12 { + recommendations.removeFirst() + } + } +} \ No newline at end of file From 6dbcae2455486458cbd1a1eb5fb40f4e530c7f55 Mon Sep 17 00:00:00 2001 From: rinaus2004-droid Date: Sat, 18 Oct 2025 20:14:35 +0000 Subject: [PATCH 2/3] Update TestFlight workflow with proper credentials --- .github/workflows/testflight-upload.yml | 65 ++++++++++++++----------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/.github/workflows/testflight-upload.yml b/.github/workflows/testflight-upload.yml index c5661daf74..23bb792dd6 100644 --- a/.github/workflows/testflight-upload.yml +++ b/.github/workflows/testflight-upload.yml @@ -1,7 +1,7 @@ -name: Build and Upload to TestFlight +name: TestFlight Upload on: - workflow_dispatch: # Manual trigger only for safety + workflow_dispatch: inputs: build_type: description: 'Build Type' @@ -12,50 +12,59 @@ on: - development - release +env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + TEAMID: "5S2WW965AG" + FASTLANE_ISSUER_ID: "289e8063-2271-4b0a-9e3b-6376644ca657" + FASTLANE_KEY_ID: "KUT22ULSV9" + jobs: build-and-upload: name: Build and Upload to TestFlight runs-on: macos-latest - # Only run if required secrets are available - if: | - secrets.TEAMID != '' && - secrets.FASTLANE_KEY_ID != '' && - secrets.FASTLANE_ISSUER_ID != '' && - secrets.FASTLANE_KEY != '' - steps: - uses: actions/checkout@v4 with: submodules: recursive - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode.app - - - name: Install Fastlane + - name: Install Apple Certificate + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + # Create keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + + # Import certificate + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output certificate.p12 + security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain + + - name: Install Ruby and Fastlane run: | gem install bundler bundle install - name: Setup Provisioning env: - TEAMID: ${{ secrets.TEAMID }} - FASTLANE_KEY_ID: ${{ secrets.FASTLANE_KEY_ID }} - FASTLANE_ISSUER_ID: ${{ secrets.FASTLANE_ISSUER_ID }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }} - GH_PAT: ${{ secrets.GH_PAT }} run: | - bundle exec fastlane setup + # Create fastlane match config + echo "FASTLANE_KEY='$FASTLANE_KEY'" > .env + bundle exec fastlane match appstore - name: Build and Upload env: - TEAMID: ${{ secrets.TEAMID }} - FASTLANE_KEY_ID: ${{ secrets.FASTLANE_KEY_ID }} - FASTLANE_ISSUER_ID: ${{ secrets.FASTLANE_ISSUER_ID }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }} - BUILD_TYPE: ${{ github.event.inputs.build_type }} run: | - if [ "$BUILD_TYPE" = "release" ]; then + if [ "${{ github.event.inputs.build_type }}" = "release" ]; then bundle exec fastlane release else bundle exec fastlane beta @@ -68,10 +77,8 @@ jobs: path: | Loop.ipa ExportOptions.plist - - - name: Upload Build Logs + + - name: Clean up keychain if: always() - uses: actions/upload-artifact@v3 - with: - name: build-logs - path: buildlog/ \ No newline at end of file + run: | + security delete-keychain build.keychain From a1bfdad0953d2d335a2cc54eb0fd3a269b4d39ce Mon Sep 17 00:00:00 2001 From: rinaus2004-droid Date: Sat, 18 Oct 2025 20:47:14 +0000 Subject: [PATCH 3/3] Fix Fastfile formatting and clean TestFlight workflow --- .github/workflows/octreotide-ci.yml | 85 +++++- .github/workflows/testflight-upload.yml | 68 ++--- fastlane/Fastfile | 335 ++++++++++++++++++++++++ 3 files changed, 443 insertions(+), 45 deletions(-) diff --git a/.github/workflows/octreotide-ci.yml b/.github/workflows/octreotide-ci.yml index 1bfcc73c85..d941fbb60b 100644 --- a/.github/workflows/octreotide-ci.yml +++ b/.github/workflows/octreotide-ci.yml @@ -15,24 +15,26 @@ jobs: test: name: Run Tests runs-on: macos-latest + strategy: + matrix: + xcode: ['14.3.1'] # Add more versions if needed + device: ['iPhone 14', 'iPhone 14 Pro', 'iPad Pro (12.9-inch) (6th generation)'] steps: - uses: actions/checkout@v4 with: submodules: recursive - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode.app - - - name: List Available Schemes - run: xcodebuild -workspace LoopWorkspace.xcworkspace -list + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - - name: Build and Test + - name: Run Unit Tests run: | xcodebuild test \ -workspace LoopWorkspace.xcworkspace \ -scheme LoopWorkspace \ - -destination 'platform=iOS Simulator,name=iPhone 14,OS=latest' \ + -destination "platform=iOS Simulator,name=${{ matrix.device }},OS=latest" \ + -only-testing:OctreotideAlgorithmTests \ -enableCodeCoverage YES \ -resultBundlePath TestResults.xcresult @@ -40,17 +42,78 @@ jobs: if: always() uses: actions/upload-artifact@v3 with: - name: test-results + name: test-results-${{ matrix.device }} path: TestResults.xcresult - name: Generate Coverage Report if: success() run: | - xcrun xccov view --report TestResults.xcresult > coverage.txt + xcrun xccov view --report TestResults.xcresult > coverage-${{ matrix.device }}.txt - name: Upload Coverage if: success() uses: actions/upload-artifact@v3 with: - name: code-coverage - path: coverage.txt \ No newline at end of file + name: code-coverage-${{ matrix.device }} + path: coverage-${{ matrix.device }}.txt + + integration-tests: + name: Integration Tests + needs: unit-tests + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Run Simulation Tests + run: | + # Run simulator with different glucose patterns + xcodebuild test \ + -workspace LoopWorkspace.xcworkspace \ + -scheme LoopWorkspace \ + -destination 'platform=iOS Simulator,name=iPhone 14,OS=latest' \ + -only-testing:GlucoseSimulatorTests \ + -resultBundlePath SimResults.xcresult + + - name: Upload Simulation Results + if: always() + uses: actions/upload-artifact@v3 + with: + name: simulation-results + path: SimResults.xcresult + + build-only: + name: Build Without Signing + needs: [unit-tests, integration-tests] + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Build for Simulator + run: | + xcodebuild build \ + -workspace LoopWorkspace.xcworkspace \ + -scheme LoopWorkspace \ + -destination 'platform=iOS Simulator,name=iPhone 14,OS=latest' \ + -configuration Debug \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + + - name: Archive Build Logs + if: always() + uses: actions/upload-artifact@v3 + with: + name: build-logs + path: ~/Library/Developer/Xcode/DerivedData/**/Logs/Build \ No newline at end of file diff --git a/.github/workflows/testflight-upload.yml b/.github/workflows/testflight-upload.yml index 23bb792dd6..155bfc8508 100644 --- a/.github/workflows/testflight-upload.yml +++ b/.github/workflows/testflight-upload.yml @@ -9,8 +9,8 @@ on: default: 'development' type: choice options: - - development - - release + - development + - release env: DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer @@ -22,23 +22,23 @@ jobs: build-and-upload: name: Build and Upload to TestFlight runs-on: macos-latest - + steps: - uses: actions/checkout@v4 - with: - submodules: recursive - + with: + submodules: recursive + - name: Install Apple Certificate - env: - BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} - P12_PASSWORD: ${{ secrets.P12_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: | + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | # Create keychain - security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security set-keychain-settings -t 3600 -u build.keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain # Import certificate echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output certificate.p12 @@ -53,32 +53,32 @@ jobs: - name: Setup Provisioning env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }} - run: | + FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }} + run: | # Create fastlane match config echo "FASTLANE_KEY='$FASTLANE_KEY'" > .env bundle exec fastlane match appstore - + - name: Build and Upload - env: - MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }} - run: | - if [ "${{ github.event.inputs.build_type }}" = "release" ]; then + env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }} + run: | + if [ "${{ github.event.inputs.build_type }}" = "release" ]; then bundle exec fastlane release - else - bundle exec fastlane beta - fi - + else + bundle exec fastlane beta + fi + - name: Upload IPA - uses: actions/upload-artifact@v3 - with: - name: loop-ipa + uses: actions/upload-artifact@v3 + with: + name: loop-ipa path: | Loop.ipa ExportOptions.plist - - - name: Clean up keychain - if: always() - run: | + + - name: Clean up keychain + if: always() + run: | security delete-keychain build.keychain diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 6b632d958a..d1a31c9680 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -282,6 +282,341 @@ platform :ios do ) end + desc "Check Certificates and Trigger Workflow for Expired or Missing Certificates" + lane :check_and_renew_certificates do + setup_ci if ENV['CI'] + ENV["MATCH_READONLY"] = false.to_s + + # Authenticate using App Store Connect API Key + api_key = app_store_connect_api_key( + key_id: ENV["FASTLANE_KEY_ID"], + issuer_id: ENV["FASTLANE_ISSUER_ID"], + key_content: ENV["FASTLANE_KEY"] # Ensure valid key content + ) + + # Initialize flag to track if renewal of certificates is needed + new_certificate_needed = false + + # Fetch all certificates + certificates = Spaceship::ConnectAPI::Certificate.all + + # Filter for Distribution Certificates + distribution_certs = certificates.select { |cert| cert.certificate_type == "DISTRIBUTION" } + + # Handle case where no distribution certificates are found + if distribution_certs.empty? + puts "No Distribution certificates found! Triggering action to create certificate." + new_certificate_needed = true + else + # Check for expiration + distribution_certs.each do |cert| + expiration_date = Time.parse(cert.expiration_date) + + puts "Current Distribution Certificate: #{cert.id}, Expiration date: #{expiration_date}" + + if expiration_date < Time.now + puts "Distribution Certificate #{cert.id} is expired! Triggering action to renew certificate." + new_certificate_needed = true + else + puts "Distribution certificate #{cert.id} is valid. No action required." + end + end + end + + # Write result to new_certificate_needed.txt + file_path = File.expand_path('new_certificate_needed.txt') + File.write(file_path, new_certificate_needed ? 'true' : 'false') + + # Log the absolute path and contents of the new_certificate_needed.txt file + puts "" + puts "Absolute path of new_certificate_needed.txt: #{file_path}" + new_certificate_needed_content = File.read(file_path) + puts "Certificate creation or renewal needed: #{new_certificate_needed_content}" + end +end# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +default_platform(:ios) + +TEAMID = ENV["TEAMID"] +GH_PAT = ENV["GH_PAT"] +GITHUB_WORKSPACE = ENV["GITHUB_WORKSPACE"] +GITHUB_REPOSITORY_OWNER = ENV["GITHUB_REPOSITORY_OWNER"] +FASTLANE_KEY_ID = ENV["FASTLANE_KEY_ID"] +FASTLANE_ISSUER_ID = ENV["FASTLANE_ISSUER_ID"] +FASTLANE_KEY = ENV["FASTLANE_KEY"] +DEVICE_NAME = ENV["DEVICE_NAME"] +DEVICE_ID = ENV["DEVICE_ID"] +ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "120" + +platform :ios do + desc "Build Loop" + lane :build_loop do + setup_ci if ENV['CI'] + + update_project_team( + path: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + teamid: "#{TEAMID}" + ) + + api_key = app_store_connect_api_key( + key_id: "#{FASTLANE_KEY_ID}", + issuer_id: "#{FASTLANE_ISSUER_ID}", + key_content: "#{FASTLANE_KEY}" + ) + + previous_build_number = latest_testflight_build_number( + app_identifier: "com.#{TEAMID}.loopkit.Loop", + api_key: api_key, + ) + + current_build_number = previous_build_number + 1 + + increment_build_number( + xcodeproj: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + build_number: current_build_number + ) + + match( + type: "appstore", + git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), + app_identifier: [ + "com.#{TEAMID}.loopkit.Loop", + "com.#{TEAMID}.loopkit.Loop.statuswidget", + "com.#{TEAMID}.loopkit.Loop.LoopWatch.watchkitextension", + "com.#{TEAMID}.loopkit.Loop.LoopWatch", + "com.#{TEAMID}.loopkit.Loop.Loop-Intent-Extension", + "com.#{TEAMID}.loopkit.Loop.LoopWidgetExtension" + ] + ) + + previous_build_number = latest_testflight_build_number( + app_identifier: "com.#{TEAMID}.loopkit.Loop", + api_key: api_key, + ) + + current_build_number = previous_build_number + 1 + + increment_build_number( + xcodeproj: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + build_number: current_build_number + ) + + mapping = Actions.lane_context[ + SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING + ] + + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + profile_name: mapping["com.#{TEAMID}.loopkit.Loop"], + code_sign_identity: "iPhone Distribution", + targets: ["Loop"] + ) + + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + code_sign_identity: "iPhone Distribution", + targets: ["LoopCore", "LoopCore-watchOS", "LoopUI"] + ) + + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + profile_name: mapping["com.#{TEAMID}.loopkit.Loop.statuswidget"], + code_sign_identity: "iPhone Distribution", + targets: ["Loop Status Extension"] + ) + + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + profile_name: mapping["com.#{TEAMID}.loopkit.Loop.LoopWatch.watchkitextension"], + code_sign_identity: "iPhone Distribution", + targets: ["WatchApp Extension"] + ) + + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + profile_name: mapping["com.#{TEAMID}.loopkit.Loop.LoopWatch"], + code_sign_identity: "iPhone Distribution", + targets: ["WatchApp"] + ) + + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + profile_name: mapping["com.#{TEAMID}.loopkit.Loop.Loop-Intent-Extension"], + code_sign_identity: "iPhone Distribution", + targets: ["Loop Intent Extension"] + ) + + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/Loop/Loop.xcodeproj", + profile_name: mapping["com.#{TEAMID}.loopkit.Loop.LoopWidgetExtension"], + code_sign_identity: "iPhone Distribution", + targets: ["Loop Widget Extension"] + ) + + gym( + export_method: "app-store", + scheme: "LoopWorkspace", + output_name: "Loop.ipa", + configuration: "Release", + destination: 'generic/platform=iOS', + buildlog_path: 'buildlog' + ) + + copy_artifacts( + target_path: "artifacts", + artifacts: ["*.mobileprovision", "*.ipa", "*.dSYM.zip"] + ) + end + + desc "Push to TestFlight" + lane :release do + api_key = app_store_connect_api_key( + key_id: "#{FASTLANE_KEY_ID}", + issuer_id: "#{FASTLANE_ISSUER_ID}", + key_content: "#{FASTLANE_KEY}" + ) + + upload_to_testflight( + api_key: api_key, + skip_submission: false, + ipa: "Loop.ipa", + skip_waiting_for_build_processing: true, + ) + end + + desc "Provision Identifiers and Certificates" + lane :identifiers do + setup_ci if ENV['CI'] + ENV["MATCH_READONLY"] = false.to_s + + app_store_connect_api_key( + key_id: "#{FASTLANE_KEY_ID}", + issuer_id: "#{FASTLANE_ISSUER_ID}", + key_content: "#{FASTLANE_KEY}" + ) + + def configure_bundle_id(name, identifier, capabilities) + bundle_id = Spaceship::ConnectAPI::BundleId.find(identifier) || Spaceship::ConnectAPI::BundleId.create( + name: name, + identifier: identifier, + platform: "IOS" + ) + existing = bundle_id.get_capabilities.map(&:capability_type) + capabilities.reject { |c| existing.include?(c) }.each do |cap| + bundle_id.create_capability(cap) + end + end + + configure_bundle_id("Loop", "com.#{TEAMID}.loopkit.Loop", [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS, + Spaceship::ConnectAPI::BundleIdCapability::Type::HEALTHKIT, + Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS, + Spaceship::ConnectAPI::BundleIdCapability::Type::SIRIKIT, + Spaceship::ConnectAPI::BundleIdCapability::Type::NFC_TAG_READING + ]) + + configure_bundle_id("Loop Intent Extension", "com.#{TEAMID}.loopkit.Loop.Loop-Intent-Extension", [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + + configure_bundle_id("Loop Status Extension", "com.#{TEAMID}.loopkit.Loop.statuswidget", [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + + configure_bundle_id("WatchApp", "com.#{TEAMID}.loopkit.Loop.LoopWatch", []) + + configure_bundle_id("WatchApp Extension", "com.#{TEAMID}.loopkit.Loop.LoopWatch.watchkitextension", [ + Spaceship::ConnectAPI::BundleIdCapability::Type::HEALTHKIT, + Spaceship::ConnectAPI::BundleIdCapability::Type::SIRIKIT + ]) + + configure_bundle_id("Loop Widget Extension", "com.#{TEAMID}.loopkit.Loop.LoopWidgetExtension", [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + + end + + desc "Provision Certificates" + lane :certs do + setup_ci if ENV['CI'] + ENV["MATCH_READONLY"] = false.to_s + + app_store_connect_api_key( + key_id: "#{FASTLANE_KEY_ID}", + issuer_id: "#{FASTLANE_ISSUER_ID}", + key_content: "#{FASTLANE_KEY}" + ) + + match( + type: "appstore", + force: false, + verbose: true, + git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), + app_identifier: [ + "com.#{TEAMID}.loopkit.Loop", + "com.#{TEAMID}.loopkit.Loop.statuswidget", + "com.#{TEAMID}.loopkit.Loop.LoopWatch.watchkitextension", + "com.#{TEAMID}.loopkit.Loop.LoopWatch", + "com.#{TEAMID}.loopkit.Loop.Loop-Intent-Extension", + "com.#{TEAMID}.loopkit.Loop.LoopWidgetExtension", + ] + ) + end + + desc "Validate Secrets" + lane :validate_secrets do + setup_ci if ENV['CI'] + ENV["MATCH_READONLY"] = true.to_s + + app_store_connect_api_key( + key_id: "#{FASTLANE_KEY_ID}", + issuer_id: "#{FASTLANE_ISSUER_ID}", + key_content: "#{FASTLANE_KEY}" + ) + + def find_bundle_id(identifier) + bundle_id = Spaceship::ConnectAPI::BundleId.find(identifier) + end + + find_bundle_id("com.#{TEAMID}.loopkit.Loop") + + match( + type: "appstore", + git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), + app_identifier: [], + ) + + end + + desc "Nuke Certs" + lane :nuke_certs do + setup_ci if ENV['CI'] + ENV["MATCH_READONLY"] = false.to_s + + app_store_connect_api_key( + key_id: "#{FASTLANE_KEY_ID}", + issuer_id: "#{FASTLANE_ISSUER_ID}", + key_content: "#{FASTLANE_KEY}" + ) + + match_nuke( + type: "appstore", + team_id: "#{TEAMID}", + skip_confirmation: true, + git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}") + ) + end + desc "Check Certificates and Trigger Workflow for Expired or Missing Certificates" lane :check_and_renew_certificates do setup_ci if ENV['CI']