Skip to content

Add remote commands via APNS for Loop users #434

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 36 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
03d1e02
Add remote commands via APNS for Loop users
codebymini Jul 9, 2025
1e43b7d
Use already available overrides from ProfileManager for Loop APNS
codebymini Jul 11, 2025
212ea49
sha1 is no longer needed
bjorkert Jul 11, 2025
56eb9c8
Removed CommonCrypto since it is not used.
bjorkert Jul 12, 2025
624e738
Undo change
bjorkert Jul 12, 2025
e1eb169
Revert APNS-related changes in Config.xcconfig from commit d5ce515
bjorkert Jul 12, 2025
b8b5118
Fix for loopapns setup not updating and reverting to default value
codebymini Jul 12, 2025
1ee9779
Fix rounding for bolus confirm button
codebymini Jul 13, 2025
55edb5a
Centralize jwt-management
bjorkert Jul 13, 2025
a3fb1ba
Use the sama apnskey, keyid and team storage as trio
bjorkert Jul 13, 2025
d5c4c10
Remove stray references to loopAPNS variables
codebymini Jul 14, 2025
e0f6cea
Avoid publishing changes from within view updates for Loop APNS setup
codebymini Jul 15, 2025
b361b43
Align buttons with TRC
bjorkert Jul 17, 2025
eafdf45
Fix for crashing camera
bjorkert Jul 17, 2025
54d9b17
cleanup
bjorkert Jul 17, 2025
4895b8a
Cleanup
bjorkert Jul 17, 2025
165e12c
Add countdown for Loop TOTP code
codebymini Jul 18, 2025
33b2054
Mitigate app hang when scanning or adding totp url
codebymini Jul 18, 2025
1d652c6
Simplified validation
bjorkert Jul 19, 2025
e44a8fc
Merge pull request #4 from CodeByMiniOrg/simplified-validation
codebymini Jul 19, 2025
6d9078d
Remove manual device token refresh and move debug info to main settings
bjorkert Jul 19, 2025
3882f2b
Add current totp code to debug / info section
codebymini Jul 19, 2025
21a59bf
Merge pull request #5 from CodeByMiniOrg/remove-device-token-refresh
bjorkert Jul 20, 2025
dc1e633
Merge remote-tracking branch 'upstream/dev' into loop-remote-commands…
bjorkert Jul 22, 2025
01f7079
Conditionally enable Nightscout remote type based on if the user is u…
bjorkert Jul 23, 2025
a1d17ce
Fix for devicetoken and bundleid missing
bjorkert Jul 23, 2025
208d7b3
Removed dead code
bjorkert Jul 23, 2025
147099d
Removed dead code
bjorkert Jul 23, 2025
3eeec54
Swapped the incorrect error invalidURL to invalidConfiguration
bjorkert Jul 23, 2025
7cb3488
Refactor OverridePresetsView to use alerts for success/error feedback…
bjorkert Jul 23, 2025
168532e
Fix typo
bjorkert Jul 23, 2025
a8c46a6
Separate error for jwt token
bjorkert Jul 23, 2025
dcfda00
Merge settings for Loop and Trio
bjorkert Jul 26, 2025
7c70fb1
Move guardrails up
bjorkert Jul 26, 2025
e1e7be5
Cleanup
bjorkert Jul 26, 2025
770caf5
Text adjustment
bjorkert Jul 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@ fastlane/test_output
fastlane/FastlaneRunner

LoopFollowConfigOverride.xcconfig
.history
99 changes: 66 additions & 33 deletions LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions LoopFollow/Controllers/Nightscout/ProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,15 @@ final class ProfileManager {
trioOverrides = []
}

Storage.shared.deviceToken.value = profileData.deviceToken ?? ""
Storage.shared.deviceToken.value = profileData.deviceToken ?? profileData.loopSettings?.deviceToken ?? ""

if let expirationDate = profileData.expirationDate {
Storage.shared.expirationDate.value = NightscoutUtils.parseDate(expirationDate)
} else {
Storage.shared.expirationDate.value = nil
}
Storage.shared.bundleId.value = profileData.bundleIdentifier ?? ""
Storage.shared.productionEnvironment.value = profileData.isAPNSProduction ?? false
Storage.shared.bundleId.value = profileData.bundleIdentifier ?? profileData.loopSettings?.bundleIdentifier ?? ""

Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? ""
}

Expand Down
53 changes: 53 additions & 0 deletions LoopFollow/Helpers/JWTManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// LoopFollow
// JWTManager.swift
// Created by Jonas Björkert.

import Foundation
import SwiftJWT

struct JWTClaims: Claims {
let iss: String
let iat: Date
}

class JWTManager {
static let shared = JWTManager()

private init() {}

func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? {
// 1. Check for a valid, non-expired JWT directly from Storage.shared
if let jwt = Storage.shared.cachedJWT.value,
let expiration = Storage.shared.jwtExpirationDate.value,
Date() < expiration
{
return jwt
}

// 2. If no valid JWT is found, generate a new one
let header = Header(kid: keyId)
let claims = JWTClaims(iss: teamId, iat: Date())
var jwt = JWT(header: header, claims: claims)

do {
let privateKey = Data(apnsKey.utf8)
let jwtSigner = JWTSigner.es256(privateKey: privateKey)
let signedJWT = try jwt.sign(using: jwtSigner)

// 3. Save the new JWT and its expiration date directly to Storage.shared
Storage.shared.cachedJWT.value = signedJWT
Storage.shared.jwtExpirationDate.value = Date().addingTimeInterval(3600) // Expires in 1 hour

return signedJWT
} catch {
LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)")
return nil
}
}

// Invalidate the cache by clearing values in Storage.shared
func invalidateCache() {
Storage.shared.cachedJWT.value = nil
Storage.shared.jwtExpirationDate.value = nil
}
}
113 changes: 113 additions & 0 deletions LoopFollow/Helpers/TOTPGenerator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// LoopFollow
// TOTPGenerator.swift
// Created by codebymini.

import CommonCrypto
import Foundation

enum TOTPGenerator {
/// Generates a TOTP code from a base32 secret
/// - Parameter secret: The base32 encoded secret
/// - Returns: A 6-digit TOTP code as a string
static func generateTOTP(secret: String) -> String {
// Decode base32 secret
let decodedSecret = base32Decode(secret)

// Get current time in 30-second intervals
let timeInterval = Int(Date().timeIntervalSince1970)
let timeStep = 30
let counter = timeInterval / timeStep

// Convert counter to 8-byte big-endian data
var counterData = Data()
for i in 0 ..< 8 {
counterData.append(UInt8((counter >> (56 - i * 8)) & 0xFF))
}

// Generate HMAC-SHA1
let key = Data(decodedSecret)
let hmac = generateHMACSHA1(key: key, data: counterData)

// Get the last 4 bits of the HMAC
let offset = Int(hmac.withUnsafeBytes { $0.last! } & 0x0F)

// Extract 4 bytes starting at the offset
let hmacData = Data(hmac)
let codeBytes = hmacData.subdata(in: offset ..< (offset + 4))

// Convert to integer and get last 6 digits
let code = codeBytes.withUnsafeBytes { bytes in
let value = bytes.load(as: UInt32.self).bigEndian
return Int(value & 0x7FFF_FFFF) % 1_000_000
}

return String(format: "%06d", code)
}

/// Extracts OTP from various URL formats
/// - Parameter urlString: The URL string to parse
/// - Returns: The OTP code as a string, or nil if not found
static func extractOTPFromURL(_ urlString: String) -> String? {
guard let url = URL(string: urlString),
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else {
return nil
}

// Check for TOTP format (otpauth://)
if url.scheme == "otpauth" {
if let secretItem = components.queryItems?.first(where: { $0.name == "secret" }),
let secret = secretItem.value
{
return generateTOTP(secret: secret)
}
}

// Check for regular OTP format
if let otpItem = components.queryItems?.first(where: { $0.name == "otp" }) {
return otpItem.value
}

return nil
}

/// Decodes a base32 string to bytes
/// - Parameter string: The base32 encoded string
/// - Returns: Array of decoded bytes
private static func base32Decode(_ string: String) -> [UInt8] {
let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
var result: [UInt8] = []
var buffer = 0
var bitsLeft = 0

for char in string.uppercased() {
guard let index = alphabet.firstIndex(of: char) else { continue }
let value = alphabet.distance(from: alphabet.startIndex, to: index)

buffer = (buffer << 5) | value
bitsLeft += 5

while bitsLeft >= 8 {
bitsLeft -= 8
result.append(UInt8((buffer >> bitsLeft) & 0xFF))
}
}

return result
}

/// Generates HMAC-SHA1 for the given key and data
/// - Parameters:
/// - key: The key to use for HMAC
/// - data: The data to hash
/// - Returns: The HMAC-SHA1 result as Data
private static func generateHMACSHA1(key: Data, data: Data) -> Data {
var hmac = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
key.withUnsafeBytes { keyBytes in
data.withUnsafeBytes { dataBytes in
CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA1), keyBytes.baseAddress, key.count, dataBytes.baseAddress, data.count, &hmac)
}
}
return Data(hmac)
}
}
91 changes: 91 additions & 0 deletions LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// LoopFollow
// SimpleQRCodeScannerView.swift
// Created by codebymini.

import AVFoundation
import SwiftUI

struct SimpleQRCodeScannerView: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentationMode
var completion: (Result<String, Error>) -> Void

// MARK: - Coordinator

class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
var parent: SimpleQRCodeScannerView
var session: AVCaptureSession?

init(parent: SimpleQRCodeScannerView) {
self.parent = parent
}

func metadataOutput(_: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from _: AVCaptureConnection) {
if let session, session.isRunning {
if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
metadataObject.type == .qr,
let stringValue = metadataObject.stringValue
{
DispatchQueue.global(qos: .userInitiated).async {
session.stopRunning()
}
parent.completion(.success(stringValue))
}
}
}
}

// MARK: - UIViewControllerRepresentable Methods

func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}

func makeUIViewController(context: Context) -> UIViewController {
let controller = UIViewController()
let session = AVCaptureSession()
context.coordinator.session = session // Assign session to coordinator

guard let videoCaptureDevice = AVCaptureDevice.default(for: .video),
let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice),
session.canAddInput(videoInput)
else {
let error = NSError(domain: "QRCodeScannerError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to set up camera input."])
completion(.failure(error))
return controller
}

session.addInput(videoInput)

let metadataOutput = AVCaptureMetadataOutput()
if session.canAddOutput(metadataOutput) {
session.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(context.coordinator, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = [.qr]
} else {
let error = NSError(domain: "QRCodeScannerError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to set up metadata output."])
completion(.failure(error))
return controller
}

let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.frame = controller.view.layer.bounds
previewLayer.videoGravity = .resizeAspectFill
controller.view.layer.addSublayer(previewLayer)

DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
}

return controller
}

func updateUIViewController(_: UIViewController, context _: Context) {}

func dismantleUIViewController(_: UIViewController, coordinator: Coordinator) {
DispatchQueue.global(qos: .userInitiated).async {
if let session = coordinator.session, session.isRunning {
session.stopRunning()
}
}
}
}
2 changes: 2 additions & 0 deletions LoopFollow/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
<string>This app requires access to contacts to update a contact image with real-time blood glucose information.</string>
<key>NSFaceIDUsageDescription</key>
<string>This app requires Face ID for secure authentication.</string>
<key>NSCameraUsageDescription</key>
<string>Used for scanning QR codes for remote authentication</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>UIApplicationSceneManifest</key>
Expand Down
35 changes: 0 additions & 35 deletions LoopFollow/Remote/Loop/LoopNightscoutRemoteView.swift

This file was deleted.

Loading