diff --git a/.github/ISSUE_TEMPLATE/general-issue.md b/.github/ISSUE_TEMPLATE/general-issue.md
new file mode 100644
index 0000000..92ea455
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/general-issue.md
@@ -0,0 +1,34 @@
+---
+name: General issue
+about: Report a reproducible bug or issue
+title: '[Bug] [Feature Request] Short description of the issue'
+labels: ''
+assignees: ''
+---
+
+## 🐞 Expected Behavior
+
+
+## ❌ Actual Behavior
+
+
+## 🔄 Steps to Reproduce
+1.
+2.
+3.
+
+## 📄 Logs / Error Messages
+
+
+## 📸 Screenshots
+
+
+## 💻 Code Snippets
+```swift
+// Include relevant code snippets that help reproduce the issue.
+```
+
+## 🛠 System Information
+- **Package Version**:
+- **Operating System**:
+- **Device / Hardware**:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..0391634
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,8 @@
+## What
+
+
+## Why
+
+
+## Changes
+
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..015422a
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,47 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ test:
+ runs-on: macos-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Select Xcode
+ run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
+
+ - name: List available iOS runtimes
+ run: xcrun simctl list runtimes
+
+ - name: Create and boot iOS Simulator
+ run: |
+ # Get the latest iOS runtime available
+ RUNTIME=$(xcrun simctl list runtimes | grep "iOS" | tail -1 | grep -oE "com\.apple\.CoreSimulator\.SimRuntime\.iOS-[0-9-]+")
+ echo "Using runtime: $RUNTIME"
+
+ # Create simulator
+ SIMULATOR_ID=$(xcrun simctl create test-device com.apple.CoreSimulator.SimDeviceType.iPhone-15 "$RUNTIME" 2>&1 | tail -1)
+ echo "Created simulator: $SIMULATOR_ID"
+
+ # Boot it
+ xcrun simctl boot "$SIMULATOR_ID"
+
+ # Wait for it to boot
+ sleep 5
+
+ echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV
+
+ - name: Run tests with iOS Simulator
+ run: |
+ xcodebuild test \
+ -scheme ZeplinPersistence \
+ -skipPackagePluginValidation \
+ -destination "id=${{ env.SIMULATOR_ID }}" \
+ -derivedDataPath .build \
+ -enableCodeCoverage YES \
+ -sdk iphonesimulator \
+ -verbose
diff --git a/.spi.yml b/.spi.yml
new file mode 100644
index 0000000..4f3d870
--- /dev/null
+++ b/.spi.yml
@@ -0,0 +1,5 @@
+version: 1
+builder:
+ configs:
+ - documentation_targets: [ZeplinPersistence]
+ platform: ios
diff --git a/.swiftformat b/.swiftformat
new file mode 100644
index 0000000..57584ae
--- /dev/null
+++ b/.swiftformat
@@ -0,0 +1,70 @@
+{
+ "fileScopedDeclarationPrivacy" : {
+ "accessLevel" : "private"
+ },
+ "indentConditionalCompilationBlocks" : true,
+ "indentSwitchCaseLabels" : false,
+ "indentation" : {
+ "spaces" : 4
+ },
+ "lineBreakAroundMultilineExpressionChainComponents" : false,
+ "lineBreakBeforeControlFlowKeywords" : false,
+ "lineBreakBeforeEachArgument" : false,
+ "lineBreakBeforeEachGenericRequirement" : false,
+ "lineLength" : 140,
+ "maximumBlankLines" : 1,
+ "multiElementCollectionTrailingCommas" : true,
+ "noAssignmentInExpressions" : {
+ "allowedFunctions" : [
+ "XCTAssertNoThrow"
+ ]
+ },
+ "prioritizeKeepingFunctionOutputTogether" : false,
+ "respectsExistingLineBreaks" : true,
+ "rules" : {
+ "AllPublicDeclarationsHaveDocumentation" : false,
+ "AlwaysUseLiteralForEmptyCollectionInit" : false,
+ "AlwaysUseLowerCamelCase" : true,
+ "AmbiguousTrailingClosureOverload" : true,
+ "BeginDocumentationCommentWithOneLineSummary" : false,
+ "DoNotUseSemicolons" : true,
+ "DontRepeatTypeInStaticProperties" : true,
+ "FileScopedDeclarationPrivacy" : true,
+ "FullyIndirectEnum" : true,
+ "GroupNumericLiterals" : true,
+ "IdentifiersMustBeASCII" : true,
+ "NeverForceUnwrap" : false,
+ "NeverUseForceTry" : false,
+ "NeverUseImplicitlyUnwrappedOptionals" : false,
+ "NoAccessLevelOnExtensionDeclaration" : true,
+ "NoAssignmentInExpressions" : true,
+ "NoBlockComments" : true,
+ "NoCasesWithOnlyFallthrough" : true,
+ "NoEmptyTrailingClosureParentheses" : true,
+ "NoLabelsInCasePatterns" : true,
+ "NoLeadingUnderscores" : false,
+ "NoParensAroundConditions" : true,
+ "NoPlaygroundLiterals" : true,
+ "NoVoidReturnOnFunctionSignature" : true,
+ "OmitExplicitReturns" : false,
+ "OneCasePerLine" : true,
+ "OneVariableDeclarationPerLine" : true,
+ "OnlyOneTrailingClosureArgument" : true,
+ "OrderedImports" : true,
+ "ReplaceForEachWithForLoop" : true,
+ "ReturnVoidInsteadOfEmptyTuple" : true,
+ "TypeNamesShouldBeCapitalized" : true,
+ "UseEarlyExits" : false,
+ "UseExplicitNilCheckInConditions" : true,
+ "UseLetInEveryBoundCaseVariable" : true,
+ "UseShorthandTypeNames" : true,
+ "UseSingleLinePropertyGetter" : true,
+ "UseSynthesizedInitializer" : true,
+ "UseTripleSlashForDocumentationComments" : true,
+ "UseWhereClausesInForLoops" : false,
+ "ValidateDocumentationComments" : false
+ },
+ "spacesAroundRangeFormationOperators" : true,
+ "tabWidth" : 4,
+ "version" : 1
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d7c428c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,34 @@
+# Change Log
+All notable changes to this project will be documented in this file.
+
+## [0.1.0] - 2026-01-12
+
+First public release of ZeplinPersistence.
+
+### Added
+- Documentation comments for all public APIs
+- DocC documentation with package overview
+- Comprehensive README with installation and usage examples
+- MIT License
+- Swift Package Index configuration
+- GitHub Actions CI workflow for running tests
+- Issue and pull request templates
+- Basic XCTest suite for CoreData persistence
+- SwiftFormat lint plugin for code consistency
+
+## [0.0.2] - 2025-01-12
+
+### Changed
+- Updated to Fetcher v0.0.2 and ZeplinKit v0.0.3
+- Made AppTarget conform to Sendable for Swift concurrency
+
+## [0.0.1] - 2025-01-12
+
+Initial internal release.
+
+### Added
+- PersistenceController for managing CoreData stack with app group support
+- TokenRepository actor for secure keychain-based token management
+- NotificationRecord entity with CoreData model
+- AppTarget enum for configuring different app targets
+- Support for iOS, macOS, and watchOS platforms
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d94f8c1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Snapp-Mobile
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Package.swift b/Package.swift
index f7f862b..c108638 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,5 +1,4 @@
// swift-tools-version: 5.6
-// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@@ -7,29 +6,29 @@ let package = Package(
name: "ZeplinPersistence",
platforms: [
.iOS(.v14),
- .macOS(.v11)
+ .macOS(.v11),
],
products: [
- // Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "ZeplinPersistence",
targets: ["ZeplinPersistence"]
- ),
+ )
],
dependencies: [
- // Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/Snapp-Mobile/Fetcher", from: "0.0.2"),
.package(url: "https://github.com/Snapp-Mobile/ZeplinKit", from: "0.0.3"),
.package(url: "https://github.com/jrendel/SwiftKeychainWrapper", from: "4.0.0"),
+ .package(url: "https://github.com/Snapp-Mobile/SwiftFormatLintPlugin.git", from: "1.0.4"),
],
targets: [
- // Targets are the basic building blocks of a package. A target can define a module or a test suite.
- // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "ZeplinPersistence",
- dependencies: ["ZeplinKit", "Fetcher", "SwiftKeychainWrapper"]),
+ dependencies: ["ZeplinKit", "Fetcher", "SwiftKeychainWrapper"],
+ plugins: [.plugin(name: "Lint", package: "SwiftFormatLintPlugin")],
+ ),
.testTarget(
name: "ZeplinPersistenceTests",
- dependencies: ["ZeplinPersistence"]),
+ dependencies: ["ZeplinPersistence"]
+ ),
]
)
diff --git a/README.md b/README.md
index 260350d..613e850 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,61 @@
+
+

+
+
# ZeplinPersistence
A Swift Package that wraps up a CoreData container for persisting user's notifications
+
+[](https://swiftpackageindex.com/Snapp-Mobile/ZeplinPersistence)
+[](https://www.apple.com/ios/)
+[](https://github.com/Snapp-Mobile/ZeplinPersistence/releases)
+[](https://github.com/Snapp-Mobile/ZeplinPersistence/actions)
+[](LICENSE)
+
+## Overview
+
+ZeplinPersistence is the data layer for the Zeplin Mobile app. It handles storing notifications using CoreData and manages authentication tokens through the iOS keychain. The package uses app groups to share data between the main app and extensions like widgets or notification extensions.
+
+The `PersistenceController` sets up a CoreData stack that can work with shared containers or in-memory stores for testing. The `TokenRepository` is an actor that safely manages OAuth tokens, handling refreshes and waiting for the device to unlock before accessing the keychain.
+
+## Installation
+
+Add ZeplinPersistence to your project using Swift Package Manager. In Xcode, go to File → Add Package Dependencies and enter the repository URL, or add it to your `Package.swift` file.
+
+```swift
+dependencies: [
+ .package(url: "https://github.com/Snapp-Mobile/ZeplinPersistence.git", from: "0.1.0")
+]
+```
+
+## Usage
+
+Create a persistence controller for your app target. The controller handles setting up the CoreData stack with the appropriate app group identifier.
+
+```swift
+import ZeplinPersistence
+
+let persistence = PersistenceController(target: .iOSApp, inMemory: false)
+let context = persistence.container.viewContext
+```
+
+For testing, use the built-in test controller that creates an in-memory store.
+
+```swift
+let testPersistence = PersistenceController.test
+```
+
+The token repository manages authentication tokens across your app. Initialize it with your keychain settings and app target.
+
+```swift
+let tokenRepo = TokenRepository(
+ key: "auth-token",
+ serviceName: "com.snapp.zeplin",
+ appTarget: .iOSApp,
+ configuration: apiConfig
+)
+```
+
+## License
+
+ZeplinPersistence is available under the MIT license. See the LICENSE file for details.
diff --git a/Sources/ZeplinPersistence/AppTarget.swift b/Sources/ZeplinPersistence/AppTarget.swift
index 5ea70ef..94a2256 100644
--- a/Sources/ZeplinPersistence/AppTarget.swift
+++ b/Sources/ZeplinPersistence/AppTarget.swift
@@ -1,12 +1,13 @@
//
// AppTarget.swift
-//
+//
//
// Created by Ilian Konchev on 5.10.21.
//
import Foundation
+/// Defines different app targets for sharing data and configuring persistence.
public enum AppTarget: String, CaseIterable, Sendable {
case macOSApp
case macOSWidget
@@ -16,6 +17,7 @@ public enum AppTarget: String, CaseIterable, Sendable {
case watchOSApp
case watchOSComplication
+ /// Transaction author identifier for CoreData history tracking.
public var transactionAuthor: String {
switch self {
case .macOSApp:
@@ -35,6 +37,7 @@ public enum AppTarget: String, CaseIterable, Sendable {
}
}
+ /// App group identifier for sharing data between targets.
public var groupIdentifier: String {
switch self {
case .macOSApp, .macOSWidget:
@@ -46,10 +49,12 @@ public enum AppTarget: String, CaseIterable, Sendable {
}
}
+ /// Shared UserDefaults instance for the app group.
public var userDefaults: UserDefaults? {
return UserDefaults(suiteName: groupIdentifier)
}
+ /// Widget kind identifier for refreshing widgets.
public var widgetKind: String? {
switch self {
case .macOSApp:
diff --git a/Sources/ZeplinPersistence/NotificationRecord+CoreDataClass.swift b/Sources/ZeplinPersistence/NotificationRecord+CoreDataClass.swift
index a2e299d..0cd6340 100644
--- a/Sources/ZeplinPersistence/NotificationRecord+CoreDataClass.swift
+++ b/Sources/ZeplinPersistence/NotificationRecord+CoreDataClass.swift
@@ -1,6 +1,6 @@
//
// NotificationRecord+CoreDataClass.swift
-//
+//
//
// Created by Ilian Konchev on 9.10.21.
// Copyright © 2021 Ilian Konchev. All rights reserved.
@@ -11,6 +11,7 @@ import CoreData
import Foundation
import ZeplinKit
+/// CoreData entity representing a Zeplin notification.
@objc(NotificationRecord)
public class NotificationRecord: NSManagedObject {
@NSManaged public var actionDescription: String
@@ -28,13 +29,14 @@ public class NotificationRecord: NSManagedObject {
@NSManaged public var remoteImageURL: URL?
@NSManaged public var screenId: String?
+ /// Standard fetch request sorted by creation date.
@nonobjc
public class func fetchRequest() -> NSFetchRequest {
let request = NSFetchRequest(entityName: "Notification")
request.propertiesToFetch = [
"actionDescription", "authorName", "authorAvatarURL", "authorEmotar",
"created", "contextDescription", "isRead", "lastUpdated", "notificationId", "projectId",
- "remoteImageURL", "screenId"
+ "remoteImageURL", "screenId",
]
request.sortDescriptors = [
NSSortDescriptor(key: "created", ascending: false)
@@ -44,6 +46,7 @@ public class NotificationRecord: NSManagedObject {
return request
}
+ /// Fetch request for notification IDs and timestamps only.
@nonobjc
public class func fetchKnownNotifications() -> NSFetchRequest {
let request = NSFetchRequest(entityName: "Notification")
@@ -81,6 +84,7 @@ public class NotificationRecord: NSManagedObject {
return request
}
+ /// Finds a notification by its ID.
@nonobjc
public class func findById(_ notificationId: String) -> NSFetchRequest {
let request = NSFetchRequest(entityName: "Notification")
@@ -90,14 +94,16 @@ public class NotificationRecord: NSManagedObject {
}
}
-public extension NotificationRecord {
- static func create(with notification: ZeplinNotification, in context: NSManagedObjectContext) {
+extension NotificationRecord {
+ /// Creates a new notification record from a Zeplin notification.
+ public static func create(with notification: ZeplinNotification, in context: NSManagedObjectContext) {
let record = NotificationRecord(context: context)
record.id = UUID()
record.update(from: notification)
}
- func update(from notification: ZeplinNotification) {
+ /// Updates the record with data from a Zeplin notification.
+ public func update(from notification: ZeplinNotification) {
created = Date(timeIntervalSince1970: notification.created)
notificationId = notification.id
let lastUpdatedInterval = notification.updated ?? notification.created
@@ -125,17 +131,21 @@ public extension NotificationRecord {
}
}
- var representation: ZeplinNotificationRepresentation {
- return ZeplinNotificationRepresentation(id: notificationId,
- created: created.timeIntervalSince1970,
- lastUpdated: lastUpdated.timeIntervalSince1970,
- isRead: isRead)
+ /// Lightweight representation of the notification for API operations.
+ public var representation: ZeplinNotificationRepresentation {
+ return ZeplinNotificationRepresentation(
+ id: notificationId,
+ created: created.timeIntervalSince1970,
+ lastUpdated: lastUpdated.timeIntervalSince1970,
+ isRead: isRead
+ )
}
- func matches(_ searchTerm: String, _ showsOnlyUnread: Bool) -> Bool {
+ /// Checks if the notification matches a search term and read status filter.
+ public func matches(_ searchTerm: String, _ showsOnlyUnread: Bool) -> Bool {
let term = searchTerm.lowercased()
- let termMatched = searchTerm.isEmpty ? true : (actionDescription.lowercased().contains(term) ||
- contextDescription.lowercased().contains(term))
+ let termMatched =
+ searchTerm.isEmpty ? true : (actionDescription.lowercased().contains(term) || contextDescription.lowercased().contains(term))
if showsOnlyUnread {
return termMatched && !isRead
} else {
@@ -143,7 +153,8 @@ public extension NotificationRecord {
}
}
- func differsFrom(_ notification: ZeplinNotification) -> Bool {
+ /// Checks if the notification has different timestamps than the API notification.
+ public func differsFrom(_ notification: ZeplinNotification) -> Bool {
let date = notification.updated ?? notification.created
return lastUpdated.timeIntervalSince1970 != date || created.timeIntervalSince1970 != notification.created
}
diff --git a/Sources/ZeplinPersistence/PersistenceController.swift b/Sources/ZeplinPersistence/PersistenceController.swift
index 647130c..07c1ff7 100644
--- a/Sources/ZeplinPersistence/PersistenceController.swift
+++ b/Sources/ZeplinPersistence/PersistenceController.swift
@@ -1,6 +1,6 @@
//
// PersistenceController.swift
-//
+//
//
// Created by Ilian Konchev on 5.10.21.
//
@@ -8,15 +8,24 @@
import CoreData
import os
+/// Configures and manages the CoreData stack for storing notifications.
public struct PersistenceController {
+ /// Test instance with in-memory store.
public static let test = PersistenceController(inMemory: true)
+ /// App instance configured for iOS with persistent storage.
public static let app = PersistenceController(target: .iOSApp, inMemory: false)
+ /// The persistent container managing the CoreData stack.
public let container: NSPersistentContainer
private let fileManager = FileManager.default
+ /// Creates a persistence controller.
+ /// - Parameters:
+ /// - target: App target for configuring shared containers.
+ /// - inMemory: Whether to use in-memory storage for testing.
public init(target: AppTarget? = nil, inMemory: Bool = false) {
if let modelURL = Bundle.module.url(forResource: "Zeplin", withExtension: "momd"),
- let model = NSManagedObjectModel(contentsOf: modelURL) {
+ let model = NSManagedObjectModel(contentsOf: modelURL)
+ {
container = NSPersistentContainer(name: "Zeplin", managedObjectModel: model)
} else {
container = NSPersistentContainer(name: "Zeplin")
@@ -27,38 +36,36 @@ public struct PersistenceController {
} else {
guard let target = target else { return }
#if !os(watchOS)
- guard let fileContainer = fileManager.containerURL(forSecurityApplicationGroupIdentifier: target.groupIdentifier) else {
- fatalError("Shared file container could not be created.")
- }
+ guard let fileContainer = fileManager.containerURL(forSecurityApplicationGroupIdentifier: target.groupIdentifier) else {
+ fatalError("Shared file container could not be created.")
+ }
- let storeURL = fileContainer.appendingPathComponent("Zeplin.sqlite")
- os_log("DB at %@", log: OSLog.app, type: .debug, storeURL.absoluteString)
- let storeDescription = NSPersistentStoreDescription(url: storeURL)
- storeDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
- storeDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
- container.persistentStoreDescriptions = [storeDescription]
+ let storeURL = fileContainer.appendingPathComponent("Zeplin.sqlite")
+ os_log("DB at %@", log: OSLog.app, type: .debug, storeURL.absoluteString)
+ let storeDescription = NSPersistentStoreDescription(url: storeURL)
+ storeDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
+ storeDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
+ container.persistentStoreDescriptions = [storeDescription]
#else
- if let location = container.persistentStoreDescriptions.first?.apiURL?.absoluteString {
- os_log("DB at %@", log: OSLog.app, type: .debug, location)
- }
+ if let location = container.persistentStoreDescriptions.first?.apiURL?.absoluteString {
+ os_log("DB at %@", log: OSLog.app, type: .debug, location)
+ }
#endif
}
- container.loadPersistentStores(completionHandler: { [weak container] _, error in // storeDescription, error
+ container.loadPersistentStores(completionHandler: { [weak container] _, error in // storeDescription, error
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate.
// You should not use this function in a shipping application, although
// it may be useful during development.
- /*
- Typical reasons for an error here include:
- * The parent directory does not exist, cannot be created, or disallows writing.
- * The persistent store is not accessible, due to permissions or data protection when the device is locked.
- * The device is out of space.
- * The store could not be migrated to the current model version.
- Check the error message to determine what the actual problem was.
- */
+ // Typical reasons for an error here include:
+ // * The parent directory does not exist, cannot be created, or disallows writing.
+ // * The persistent store is not accessible, due to permissions or data protection when the device is locked.
+ // * The device is out of space.
+ // * The store could not be migrated to the current model version.
+ // Check the error message to determine what the actual problem was.
fatalError("Unresolved error \(error), \(error.userInfo)")
}
diff --git a/Sources/ZeplinPersistence/TokenRepository.swift b/Sources/ZeplinPersistence/TokenRepository.swift
index 785a38f..6b0f473 100644
--- a/Sources/ZeplinPersistence/TokenRepository.swift
+++ b/Sources/ZeplinPersistence/TokenRepository.swift
@@ -1,6 +1,6 @@
//
// TokenRepository.swift
-//
+//
//
// Created by Ilian Konchev on 26.11.21.
//
@@ -28,11 +28,13 @@ public actor TokenRepository {
/// - accessibility: The accessibility level of the stored data
/// - serviceName: The name of the service to set for the keychain item
/// - appTarget: The app target whose group identifier is going be used for setting the access group
- public init(key: String,
- accessibility: KeychainItemAccessibility? = nil,
- serviceName: String,
- appTarget: AppTarget,
- configuration: ZeplinAPIConfiguration) {
+ public init(
+ key: String,
+ accessibility: KeychainItemAccessibility? = nil,
+ serviceName: String,
+ appTarget: AppTarget,
+ configuration: ZeplinAPIConfiguration
+ ) {
self.keychain = KeychainWrapper(serviceName: serviceName, accessGroup: appTarget.groupIdentifier)
self.key = key
self.accessibility = accessibility
@@ -86,7 +88,7 @@ public actor TokenRepository {
}
if let handle = updateTokenTask {
- return try await handle.value
+ return try await handle.value
}
let logger = fetcher.environment.apiErrorsLogger
@@ -135,7 +137,7 @@ public actor TokenRepository {
while attempt < 5 {
if await UIApplication.shared.isProtectedDataAvailable {
guard let data = keychain.data(forKey: key, withAccessibility: accessibility),
- let token = try? JSONDecoder().decode(Token.self, from: data)
+ let token = try? JSONDecoder().decode(Token.self, from: data)
else {
await logger?.logMessage("Unable to decode the token from the keychain, returning nil.")
return nil
diff --git a/Sources/ZeplinPersistence/ZeplinPersistence.docc/Documentation.md b/Sources/ZeplinPersistence/ZeplinPersistence.docc/Documentation.md
new file mode 100644
index 0000000..4b47c51
--- /dev/null
+++ b/Sources/ZeplinPersistence/ZeplinPersistence.docc/Documentation.md
@@ -0,0 +1,17 @@
+# ``ZeplinPersistence``
+
+@Metadata {
+ @PageImage(purpose: icon, source:"ZeplinPersistance-logo")
+}
+
+A Swift Package that wraps up a CoreData container for persisting user's notifications
+
+## Overview
+
+ZeplinPersistence handles data storage for the Zeplin Mobile app. It wraps CoreData for storing notifications and manages authentication tokens through the iOS keychain.
+
+The ``PersistenceController`` sets up a CoreData container that uses app groups for sharing data between the main app and extensions. It supports both in-memory stores for testing and persistent stores for production. The ``TokenRepository`` is an actor that handles token storage and refreshing, waiting for protected data availability when the device is locked.
+
+Different app targets like widgets or notification extensions get their own configuration through ``AppTarget`` to handle security and data sharing.
+
+## Topics
diff --git a/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo.png b/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo.png
new file mode 100644
index 0000000..b2bfe34
Binary files /dev/null and b/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo.png differ
diff --git a/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo@2x.png b/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo@2x.png
new file mode 100644
index 0000000..04f5ca2
Binary files /dev/null and b/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo@2x.png differ
diff --git a/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo@3x.png b/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo@3x.png
new file mode 100644
index 0000000..bc08926
Binary files /dev/null and b/Sources/ZeplinPersistence/ZeplinPersistence.docc/Resources/ZeplinPersistance-logo@3x.png differ
diff --git a/Tests/ZeplinPersistenceTests/ZeplinPersistenceTests.swift b/Tests/ZeplinPersistenceTests/ZeplinPersistenceTests.swift
index 9bf660d..2376d94 100644
--- a/Tests/ZeplinPersistenceTests/ZeplinPersistenceTests.swift
+++ b/Tests/ZeplinPersistenceTests/ZeplinPersistenceTests.swift
@@ -1,11 +1,32 @@
+import CoreData
import XCTest
+
@testable import ZeplinPersistence
final class ZeplinPersistenceTests: XCTestCase {
- func testExample() throws {
- // This is an example of a functional test case.
- // Use XCTAssert and related functions to verify your tests produce the correct
- // results.
- XCTAssertTrue(true)
+ func testInMemoryPersistenceStoresSaveAndFetchNotifications() throws {
+ let controller = PersistenceController.test
+ let context = controller.container.viewContext
+
+ let notification = NotificationRecord(context: context)
+ notification.id = UUID()
+ notification.notificationId = "test-123"
+ notification.actionDescription = "commented on"
+ notification.contextDescription = "Design System"
+ notification.authorName = "Test User"
+ notification.created = Date()
+ notification.lastUpdated = Date()
+ notification.isRead = false
+ notification.originalData = Data()
+
+ try context.save()
+
+ let fetchRequest = NotificationRecord.findById("test-123")
+ let results = try context.fetch(fetchRequest)
+
+ XCTAssertEqual(results.count, 1)
+ XCTAssertEqual(results.first?.notificationId, "test-123")
+ XCTAssertEqual(results.first?.authorName, "Test User")
+ XCTAssertFalse(results.first?.isRead ?? true)
}
}