From b28f9c28a72eeda86d09f43db5783d81dbd640a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 16:09:47 +0100 Subject: [PATCH 1/3] Scope all identifiers to bundle ID for multi-instance support Derive BGTask IDs, notification IDs, URL schemes, and notification categories from Bundle.main.bundleIdentifier so that LoopFollow, LoopFollow_Second, and LoopFollow_Third each get isolated identifiers and don't interfere with each other's background tasks, notifications, or Live Activities. Also show the configured display name in the Live Activity footer (next to the update time) when the existing "Show Display Name" toggle is enabled, so users can identify which instance a LA belongs to. --- LoopFollow/Application/AppDelegate.swift | 4 +-- LoopFollow/Application/SceneDelegate.swift | 2 +- .../Controllers/BackgroundAlertManager.swift | 25 +++++++++++++----- .../Helpers/BackgroundRefreshManager.swift | 2 +- LoopFollow/Info.plist | 4 +-- LoopFollow/LiveActivity/AppGroupID.swift | 26 ++++++++++++++----- .../LiveActivity/LAAppGroupSettings.swift | 17 ++++++++++++ .../LiveActivity/LiveActivityManager.swift | 10 +++++-- .../RestartLiveActivityIntent.swift | 2 +- .../LoopFollowLiveActivity.swift | 16 +++++++----- 10 files changed, 80 insertions(+), 28 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d79de7d18..62be95fd4 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -32,7 +32,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) - let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: []) + let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) UNUserNotificationCenter.current().setNotificationCategories([category]) UNUserNotificationCenter.current().delegate = self @@ -107,7 +107,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate - // handles loopfollow://la-tap for Live Activity tap navigation. + // handles ://la-tap for Live Activity tap navigation. // MARK: UISceneSession Lifecycle diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index 3819a7ac6..e702db267 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -35,7 +35,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func scene(_: UIScene, openURLContexts URLContexts: Set) { - guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } + guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return } // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app // foregrounds from background. Post on the next run loop so the view // hierarchy (including any presented modals) is fully settled. diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index d8a80b8f1..0ba3664b1 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -11,11 +11,24 @@ enum BackgroundAlertDuration: TimeInterval, CaseIterable { case eighteenMinutes = 1080 // 18 minutes in seconds } -/// Enum representing unique identifiers for each background alert. -enum BackgroundAlertIdentifier: String, CaseIterable { - case sixMin = "loopfollow.background.alert.6min" - case twelveMin = "loopfollow.background.alert.12min" - case eighteenMin = "loopfollow.background.alert.18min" +/// Unique identifiers for each background alert, scoped to the current bundle +/// so multiple LoopFollow instances don't interfere with each other's notifications. +enum BackgroundAlertIdentifier: CaseIterable { + case sixMin + case twelveMin + case eighteenMin + + private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow" + + var rawValue: String { + switch self { + case .sixMin: "\(Self.prefix).background.alert.6min" + case .twelveMin: "\(Self.prefix).background.alert.12min" + case .eighteenMin: "\(Self.prefix).background.alert.18min" + } + } + + static let categoryIdentifier = "\(prefix).background.alert" } class BackgroundAlertManager { @@ -118,7 +131,7 @@ class BackgroundAlertManager { content.title = title content.body = body content.sound = .defaultCritical - content.categoryIdentifier = "loopfollow.background.alert" + content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier return content } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index bac7e1c8e..a1168174d 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -8,7 +8,7 @@ class BackgroundRefreshManager { static let shared = BackgroundRefreshManager() private init() {} - private let taskIdentifier = "com.loopfollow.audiorefresh" + private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh" func register() { BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 5cc7f4146..9e0f99340 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,7 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) - com.loopfollow.audiorefresh + com.$(unique_id).LoopFollow$(app_suffix).audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -34,7 +34,7 @@ CFBundleURLSchemes - loopfollow + loopfollow$(app_suffix) diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 6fc2bb9a6..5eb1187b8 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -20,19 +20,33 @@ enum AppGroupID { /// to force a shared base bundle id (recommended for reliability). private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" - static func current() -> String { + /// The base bundle identifier for the main app, with extension suffixes stripped. + /// Usable from both the main app and extensions. + static var baseBundleID: String { if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return "group.\(base)" + return base } - let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + return stripLikelyExtensionSuffixes(from: bundleID) + } - // Heuristic: strip common extension suffixes so the extension can land on the main app’s group id. - let base = stripLikelyExtensionSuffixes(from: bundleID) + /// URL scheme derived from the bundle identifier. Works across app and extensions. + /// Default build: "loopfollow", second: "loopfollow2", third: "loopfollow3", etc. + static var urlScheme: String { + let base = baseBundleID + // Extract the suffix after "LoopFollow" in the bundle ID + // e.g. "com.TEAM.LoopFollow2" → "2", "com.TEAM.LoopFollow" → "" + if let range = base.range(of: "LoopFollow", options: .backwards) { + let suffix = base[range.upperBound...] + return "loopfollow\(suffix)" + } + return "loopfollow" + } - return "group.\(base)" + static func current() -> String { + "group.\(baseBundleID)" } private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String { diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 8fedeb155..b61487f27 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -135,6 +135,8 @@ enum LAAppGroupSettings { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" static let slots = "la.slots" + static let displayName = "la.displayName" + static let showDisplayName = "la.showDisplayName" } private static var defaults: UserDefaults? { @@ -176,4 +178,19 @@ enum LAAppGroupSettings { } return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } } + + // MARK: - Display Name + + static func setDisplayName(_ name: String, show: Bool) { + defaults?.set(name, forKey: Keys.displayName) + defaults?.set(show, forKey: Keys.showDisplayName) + } + + static func displayName() -> String { + defaults?.string(forKey: Keys.displayName) ?? "LoopFollow" + } + + static func showDisplayName() -> Bool { + defaults?.bool(forKey: Keys.showDisplayName) ?? false + } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 9faa8a41e..bab2af0e1 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -310,6 +310,10 @@ final class LiveActivityManager { lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value, ) + LAAppGroupSettings.setDisplayName( + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", + show: Storage.shared.showDisplayName.value + ) GlucoseSnapshotStore.shared.save(snapshot) } startIfNeeded() @@ -557,6 +561,8 @@ final class LiveActivityManager { // MARK: - Renewal Notifications + private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed" + private func scheduleRenewalFailedNotification() { let content = UNMutableNotificationContent() content.title = "Live Activity Expiring" @@ -564,7 +570,7 @@ final class LiveActivityManager { content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest( - identifier: "loopfollow.la.renewal.failed", + identifier: LiveActivityManager.renewalNotificationID, content: content, trigger: trigger, ) @@ -577,7 +583,7 @@ final class LiveActivityManager { } private func cancelRenewalFailedNotification() { - let id = "loopfollow.la.renewal.failed" + let id = LiveActivityManager.renewalNotificationID UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) } diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index cb1f84d18..00740e10e 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -15,7 +15,7 @@ struct RestartLiveActivityIntent: AppIntent { let apnsKey = Storage.shared.lfApnsKey.value if keyId.isEmpty || apnsKey.isEmpty { - if let url = URL(string: "loopfollow://settings/live-activity") { + if let url = URL(string: "\(AppGroupID.urlScheme)://settings/live-activity") { await MainActor.run { UIApplication.shared.open(url) } } return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 5c28eed3b..1abbb6c81 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -9,21 +9,21 @@ import WidgetKit private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandLeadingView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } .id(context.state.seq) } DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandTrailingView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } .id(context.state.seq) } DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandBottomView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) } @@ -51,7 +51,7 @@ struct LoopFollowLiveActivityWidget: Widget { .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!) } dynamicIsland: { context in makeDynamicIsland(context: context) } @@ -106,7 +106,7 @@ private struct LockScreenFamilyAdaptiveView: View { .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!) } } } @@ -206,8 +206,10 @@ private struct LockScreenLiveActivityView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - // Footer: last update time - Text("Last Update: \(LAFormat.updated(s))") + // Footer: last update time, optionally prefixed with display name + Text(LAAppGroupSettings.showDisplayName() + ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))" + : "Last Update: \(LAFormat.updated(s))") .font(.system(size: 11, weight: .regular, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.65)) From 8e2e31f128a870e8a906dca5858a0f43eecad4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 16:11:09 +0100 Subject: [PATCH 2/3] Linting --- .../LiveActivity/StorageCurrentGlucoseStateProvider.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 90e74f5b8..b1a416b97 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,5 +1,5 @@ +// LoopFollow // StorageCurrentGlucoseStateProvider.swift -// 2026-03-21 import Foundation @@ -7,7 +7,6 @@ import Foundation /// This is the only file in the pipeline that is allowed to touch Storage.shared /// or Observable.shared — all other layers read exclusively from this provider. struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { - // MARK: - Core Glucose var glucoseMgdl: Double? { From f2f87b5bf1c09939a9fe217e60203f4bfd5bd99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 16:22:13 +0100 Subject: [PATCH 3/3] Add migration step 7: cancel legacy notification identifiers Users upgrading from the old hardcoded identifiers would have orphaned pending notifications that the new bundle-ID-scoped code can't cancel. This one-time migration cleans them up on first launch. --- LoopFollow/Storage/Storage+Migrate.swift | 16 ++++++++++++++++ .../ViewControllers/MainViewController.swift | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index aa0868543..97cbb8b18 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -2,6 +2,7 @@ // Storage+Migrate.swift import Foundation +import UserNotifications extension Storage { func migrateStep5() { @@ -32,6 +33,21 @@ extension Storage { } } + func migrateStep7() { + // Cancel notifications scheduled with old hardcoded identifiers. + // Replaced with bundle-ID-scoped identifiers for multi-instance support. + LogManager.shared.log(category: .general, message: "Running migrateStep7 — cancel legacy notification identifiers") + + let legacyNotificationIDs = [ + "loopfollow.background.alert.6min", + "loopfollow.background.alert.12min", + "loopfollow.background.alert.18min", + "loopfollow.la.renewal.failed", + ] + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: legacyNotificationIDs) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs) + } + func migrateStep6() { // APNs credential separation LogManager.shared.log(category: .general, message: "Running migrateStep6 — APNs credential separation") diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index bbf2de63c..61925da91 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -178,6 +178,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele Storage.shared.migrationStep.value = 6 } + if Storage.shared.migrationStep.value < 7 { + Storage.shared.migrateStep7() + Storage.shared.migrationStep.value = 7 + } + // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes()