Skip to content
Draft
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
4 changes: 2 additions & 2 deletions .github/workflows/build_LoopFollow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ jobs:
build:
name: Build
needs: [check_certs, check_status]
runs-on: macos-15
runs-on: macos-26
permissions:
contents: write
if:
Expand All @@ -175,7 +175,7 @@ jobs:
(vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' )
steps:
- name: Select Xcode version
run: "sudo xcode-select --switch /Applications/Xcode_16.4.app/Contents/Developer"
run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer"

- name: Checkout Repo for building
uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,4 @@ fastlane/test_output
fastlane/FastlaneRunner

LoopFollowConfigOverride.xcconfig
.history
.history*.xcuserstate
318 changes: 283 additions & 35 deletions LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

This file was deleted.

10 changes: 10 additions & 0 deletions LoopFollow/Controllers/Nightscout/BGData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,19 @@ extension MainViewController {
Observable.shared.deltaText.value = "+" + Localizer.toDisplayUnits(String(deltaBG))
}

// Live Activity storage
Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime
Storage.shared.lastDeltaMgdl.value = Double(deltaBG)
Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction

// Mark BG data as loaded for initial loading state
self.markDataLoaded("bg")

// Live Activity update
if #available(iOS 16.1, *) {
LiveActivityManager.shared.refreshFromCurrentState(reason: "bg")
}

// Update contact
if Storage.shared.contactEnabled.value {
self.contactImageUpdater
Expand Down
8 changes: 8 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ extension MainViewController {

if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 {
IsNotLooping = true
Observable.shared.isNotLooping.value = true
statusStackView.distribution = .fill

PredictionLabel.isHidden = true
Expand All @@ -55,9 +56,13 @@ extension MainViewController {
LoopStatusLabel.text = "⚠️ Not Looping!"
LoopStatusLabel.textColor = UIColor.systemYellow
LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18)
if #available(iOS 16.1, *) {
LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping")
}

} else {
IsNotLooping = false
Observable.shared.isNotLooping.value = false
statusStackView.distribution = .fillEqually
PredictionLabel.isHidden = false

Expand All @@ -72,6 +77,9 @@ extension MainViewController {
case .system:
LoopStatusLabel.textColor = UIColor.label
}
if #available(iOS 16.1, *) {
LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed")
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ extension MainViewController {
LoopStatusLabel.text = "↻"
latestLoopStatusString = "↻"
}

// Live Activity storage
Storage.shared.lastIOB.value = latestIOB?.value
Storage.shared.lastCOB.value = latestCOB?.value
if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject],
let values = predictdata["values"] as? [Double]
{
Storage.shared.projectedBgMgdl.value = values.last
} else {
Storage.shared.projectedBgMgdl.value = nil
}
}
}
}
5 changes: 5 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ extension MainViewController {
LoopStatusLabel.text = "↻"
latestLoopStatusString = "↻"
}

// Live Activity storage
Storage.shared.lastIOB.value = latestIOB?.value
Storage.shared.lastCOB.value = latestCOB?.value
Storage.shared.projectedBgMgdl.value = nil
}
}
}
108 changes: 82 additions & 26 deletions LoopFollow/Helpers/JWTManager.swift
Original file line number Diff line number Diff line change
@@ -1,52 +1,108 @@
// LoopFollow
// JWTManager.swift

import CryptoKit
import Foundation
import SwiftJWT

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

class JWTManager {
static let shared = JWTManager()

private struct CachedToken {
let jwt: String
let expiresAt: Date
}

/// Cache keyed by "keyId:teamId", 55 min TTL
private var cache: [String: CachedToken] = [:]
private let ttl: TimeInterval = 55 * 60

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
}
let cacheKey = "\(keyId):\(teamId)"

// 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)
if let cached = cache[cacheKey], Date() < cached.expiresAt {
return cached.jwt
}

do {
let privateKey = Data(apnsKey.utf8)
let jwtSigner = JWTSigner.es256(privateKey: privateKey)
let signedJWT = try jwt.sign(using: jwtSigner)
let privateKey = try loadPrivateKey(from: apnsKey)
let header = try encodeHeader(keyId: keyId)
let payload = try encodePayload(teamId: teamId)
let signingInput = "\(header).\(payload)"

guard let signingData = signingInput.data(using: .utf8) else {
LogManager.shared.log(category: .apns, message: "Failed to encode JWT signing input")
return nil
}

// 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
let signature = try privateKey.signature(for: signingData)
let signatureBase64 = base64URLEncode(signature.rawRepresentation)
let signedJWT = "\(signingInput).\(signatureBase64)"

cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl))
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
cache.removeAll()
}

// MARK: - Private Helpers

private func loadPrivateKey(from apnsKey: String) throws -> P256.Signing.PrivateKey {
let cleaned = apnsKey
.replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "")
.replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "")
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.trimmingCharacters(in: .whitespaces)

guard let keyData = Data(base64Encoded: cleaned) else {
throw JWTError.keyDecodingFailed
}

return try P256.Signing.PrivateKey(derRepresentation: keyData)
}

private func encodeHeader(keyId: String) throws -> String {
let header: [String: String] = [
"alg": "ES256",
"kid": keyId,
]
let data = try JSONSerialization.data(withJSONObject: header)
return base64URLEncode(data)
}

private func encodePayload(teamId: String) throws -> String {
let now = Int(Date().timeIntervalSince1970)
let payload: [String: Any] = [
"iss": teamId,
"iat": now,
]
let data = try JSONSerialization.data(withJSONObject: payload)
return base64URLEncode(data)
}

private func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}

private enum JWTError: Error, LocalizedError {
case keyDecodingFailed

var errorDescription: String? {
switch self {
case .keyDecodingFailed:
return "Failed to decode APNs p8 key content. Ensure it is valid base64."
}
}
}
}
4 changes: 4 additions & 0 deletions LoopFollow/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleGetInfoString</key>
<string></string>
<key>CFBundleIconFile</key>
<string> Activities</string>
<key>CFBundleIdentifier</key>
<string>com.$(unique_id).LoopFollow$(app_suffix)</string>
<key>CFBundleInfoDictionaryVersion</key>
Expand Down Expand Up @@ -61,6 +63,8 @@
<string>This app requires Face ID for secure authentication.</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
Loading