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 20 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Config.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
unique_id = ${DEVELOPMENT_TEAM}

//Version (DEFAULT)
LOOP_FOLLOW_MARKETING_VERSION = 3.0.7
LOOP_FOLLOW_MARKETING_VERSION = 2.8.10
103 changes: 70 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.

2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/ProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ final class ProfileManager {
Storage.shared.expirationDate.value = nil
}
Storage.shared.bundleId.value = profileData.bundleIdentifier ?? ""
Storage.shared.productionEnvironment.value = profileData.isAPNSProduction ?? false

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