Skip to content

Commit c55f7ba

Browse files
Initial release of ForgePush 1.0.0
Push notification management for iOS, split into four focused libraries plus an umbrella. Libraries: - ForgePushPermission — request, check, and observe UNAuthorizationOptions. Handles provisional and time-sensitive. - ForgePushToken — PushTokenManager bridges APNs delegate callbacks into an async token stream with thread-safe state. - ForgeSilentPush — SilentPushRouter dispatches background pushes to handlers concurrently via TaskGroup. Aggregates results (.newData > .failed > .noData). - ForgeVisiblePush — VisiblePushRouter dispatches tapped notifications to matching handlers. - ForgePush — umbrella that re-exports all four. All routers take protocol dependencies (ConnectivityObserving, ProtectedDataObserving) so callers can use ForgeObservers or substitute their own implementations. Requirements: iOS 18+, macOS 15+, Swift 6.3+.
0 parents  commit c55f7ba

60 files changed

Lines changed: 7884 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deploy-docs.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Deploy docs site
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "docs/**"
8+
- ".github/workflows/deploy-docs.yml"
9+
workflow_dispatch:
10+
11+
permissions:
12+
contents: read
13+
pages: write
14+
id-token: write
15+
16+
concurrency:
17+
group: "pages"
18+
cancel-in-progress: false
19+
20+
jobs:
21+
build:
22+
runs-on: ubuntu-latest
23+
defaults:
24+
run:
25+
working-directory: docs
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- uses: pnpm/action-setup@v4
30+
with:
31+
version: 10
32+
33+
- uses: actions/setup-node@v4
34+
with:
35+
node-version: 22
36+
cache: pnpm
37+
cache-dependency-path: docs/pnpm-lock.yaml
38+
39+
- name: Install dependencies
40+
run: pnpm install --frozen-lockfile
41+
42+
- name: Build Astro site
43+
run: pnpm build
44+
45+
- uses: actions/configure-pages@v5
46+
47+
- uses: actions/upload-pages-artifact@v3
48+
with:
49+
path: docs/dist
50+
51+
deploy:
52+
needs: build
53+
runs-on: ubuntu-latest
54+
environment:
55+
name: github-pages
56+
url: ${{ steps.deployment.outputs.page_url }}
57+
steps:
58+
- id: deployment
59+
uses: actions/deploy-pages@v4

.gitignore

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# macOS
2+
.DS_Store
3+
4+
# Swift Package Manager
5+
.build/
6+
.swiftpm/
7+
Package.resolved
8+
Packages/
9+
10+
# Xcode
11+
*.xcodeproj/
12+
*.xcworkspace/
13+
xcuserdata/
14+
DerivedData/
15+
*.xcuserstate
16+
.netrc
17+
18+
# Astro / docs site
19+
docs/node_modules/
20+
docs/dist/
21+
docs/.astro/
22+
docs/.output/
23+
docs/.vercel/
24+
25+
# Internal planning artifacts — not for public repos
26+
docs/superpowers/
27+
28+
# Env
29+
.env
30+
.env.*

Package.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// swift-tools-version: 6.3
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "ForgePush",
7+
platforms: [
8+
.iOS(.v18),
9+
.macOS(.v15),
10+
],
11+
products: [
12+
.library(name: "ForgePushPermission", targets: ["ForgePushPermission"]),
13+
.library(name: "ForgePushToken", targets: ["ForgePushToken"]),
14+
.library(name: "ForgeSilentPush", targets: ["ForgeSilentPush"]),
15+
.library(name: "ForgeVisiblePush", targets: ["ForgeVisiblePush"]),
16+
.library(name: "ForgePush", targets: ["ForgePush"]),
17+
],
18+
dependencies: [
19+
.package(path: "../ForgeCore"),
20+
.package(path: "../ForgeObservers"),
21+
],
22+
targets: [
23+
.target(name: "ForgePushPermission"),
24+
.target(
25+
name: "ForgePushToken",
26+
dependencies: [
27+
.product(name: "ForgeCore", package: "ForgeCore"),
28+
]
29+
),
30+
.target(
31+
name: "ForgeSilentPush",
32+
dependencies: [
33+
.product(name: "ForgeCore", package: "ForgeCore"),
34+
.product(name: "ForgeObservers", package: "ForgeObservers"),
35+
]
36+
),
37+
.target(
38+
name: "ForgeVisiblePush",
39+
dependencies: [
40+
.product(name: "ForgeCore", package: "ForgeCore"),
41+
.product(name: "ForgeObservers", package: "ForgeObservers"),
42+
]
43+
),
44+
.target(
45+
name: "ForgePush",
46+
dependencies: [
47+
"ForgePushPermission",
48+
"ForgePushToken",
49+
"ForgeSilentPush",
50+
"ForgeVisiblePush",
51+
]
52+
),
53+
.testTarget(name: "ForgePushTokenTests", dependencies: ["ForgePushToken"]),
54+
.testTarget(name: "ForgeSilentPushTests", dependencies: ["ForgeSilentPush"]),
55+
.testTarget(name: "ForgeVisiblePushTests", dependencies: ["ForgeVisiblePush"]),
56+
],
57+
swiftLanguageModes: [.v6]
58+
)

README.md

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# ForgePush
2+
3+
Push notification management for iOS — permissions, tokens, silent and visible push routing.
4+
5+
## Requirements
6+
7+
- iOS 16+
8+
- Swift 6.0+
9+
10+
## Installation
11+
12+
### Swift Package Manager
13+
14+
Add ForgePush to your project via Xcode:
15+
16+
1. **File > Add Package Dependencies...**
17+
2. Enter the repository URL
18+
3. Select the version rule and add to your target
19+
20+
Or add it directly to your `Package.swift`:
21+
22+
```swift
23+
dependencies: [
24+
.package(url: "https://github.com/stefanprojchev/ForgePush.git", from: "1.0.0")
25+
]
26+
```
27+
28+
Import everything with `ForgePush`, or pick individual modules:
29+
30+
```swift
31+
import ForgePush // all modules
32+
import ForgePushPermission // just permission
33+
import ForgePushToken // just token management
34+
import ForgeSilentPush // just silent push routing
35+
import ForgeVisiblePush // just visible push routing
36+
```
37+
38+
## Quick Start
39+
40+
```swift
41+
import ForgePush
42+
43+
// Request permission
44+
let permission = PushPermission()
45+
let granted = try await permission.request()
46+
47+
// Register for remote notifications
48+
let tokenManager = PushTokenManager()
49+
await tokenManager.registerForRemoteNotifications()
50+
51+
// Route silent pushes
52+
let silentRouter = SilentPushRouter(
53+
connectivity: connectivityObserver,
54+
protectedData: protectedDataObserver
55+
)
56+
silentRouter.addHandler(DataSyncHandler())
57+
58+
// Route tapped notifications
59+
let visibleRouter = VisiblePushRouter(
60+
connectivity: connectivityObserver,
61+
protectedData: protectedDataObserver
62+
)
63+
visibleRouter.addHandler(DeepLinkHandler())
64+
```
65+
66+
## ForgePushPermission
67+
68+
Wraps `UNUserNotificationCenter` for requesting and checking authorization:
69+
70+
```swift
71+
let permission = PushPermission()
72+
73+
// Request (defaults to alert, badge, sound)
74+
let granted = try await permission.request()
75+
let granted = try await permission.request([.alert, .sound, .criticalAlert])
76+
77+
// Check current status
78+
let status = await permission.status() // .authorized, .denied, .notDetermined, ...
79+
80+
// Open Settings.app notification page
81+
await permission.openSettings()
82+
```
83+
84+
## ForgePushToken
85+
86+
Manages the device push token lifecycle. Provides the current token as a hex string and an `AsyncStream` for changes:
87+
88+
```swift
89+
let tokenManager = PushTokenManager()
90+
await tokenManager.registerForRemoteNotifications()
91+
92+
// Current token
93+
if let token = tokenManager.token {
94+
await sendToServer(token)
95+
}
96+
97+
// Stream token changes
98+
for await token in tokenManager.tokenStream {
99+
if let token {
100+
await sendToServer(token)
101+
}
102+
}
103+
```
104+
105+
Wire up the AppDelegate callbacks:
106+
107+
```swift
108+
func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
109+
tokenManager.didRegister(deviceToken: token)
110+
}
111+
112+
func application(_ app: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
113+
tokenManager.didFailToRegister(error: error)
114+
}
115+
```
116+
117+
## ForgeSilentPush
118+
119+
Routes silent push notifications to registered handlers concurrently. Each handler declares which payloads it matches and returns a `SilentPushResult` (`.newData`, `.noData`, `.failed`). The router aggregates results — most optimistic wins.
120+
121+
```swift
122+
struct DataSyncHandler: SilentPushHandler {
123+
let id = "sync.data"
124+
125+
func matchesPayload(_ payload: [AnyHashable: Any]) -> Bool {
126+
payload["type"] as? String == "sync"
127+
}
128+
129+
func handle(_ payload: [AnyHashable: Any], context: SilentPushContext) async -> SilentPushResult {
130+
guard context.connectivity.isConnected else { return .failed }
131+
await performSync()
132+
return .newData
133+
}
134+
}
135+
136+
// In AppDelegate
137+
func application(_ app: UIApplication, didReceiveRemoteNotification payload: [AnyHashable: Any],
138+
fetchCompletionHandler handler: @escaping (UIBackgroundFetchResult) -> Void) {
139+
silentRouter.handlePush(payload: payload, completionHandler: handler)
140+
}
141+
```
142+
143+
## ForgeVisiblePush
144+
145+
Routes tapped push notifications to registered handlers concurrently:
146+
147+
```swift
148+
struct DeepLinkHandler: VisiblePushHandler {
149+
let id = "deeplink"
150+
151+
func matches(_ response: UNNotificationResponse) -> Bool {
152+
response.notification.request.content.userInfo["deeplink"] != nil
153+
}
154+
155+
func handle(_ response: UNNotificationResponse, context: VisiblePushContext) async {
156+
let link = response.notification.request.content.userInfo["deeplink"] as! String
157+
await navigate(to: link)
158+
}
159+
}
160+
161+
// In UNUserNotificationCenterDelegate
162+
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
163+
withCompletionHandler handler: @escaping () -> Void) {
164+
visibleRouter.handleResponse(response, completionHandler: handler)
165+
}
166+
```
167+
168+
## Thread Safety
169+
170+
All types are `Sendable`. `PushTokenManager` protects state with `LockedState`. `SilentPushRouter` and `VisiblePushRouter` protect handler lists with `LockedState` and dispatch work via `TaskGroup`. `PushPermission` is a stateless struct.
171+
172+
## Forge Ecosystem
173+
174+
ForgePush is part of the **Forge** family of Swift packages for iOS:
175+
176+
| Package | Description |
177+
|---------|-------------|
178+
| [ForgeCore](https://github.com/stefanprojchev/ForgeCore) | Thread-safe utilities — `LockedState` and `SendableFileManager` |
179+
| [ForgeInject](https://github.com/stefanprojchev/ForgeInject) | Lightweight dependency injection with property wrapper |
180+
| [ForgeObservers](https://github.com/stefanprojchev/ForgeObservers) | Reactive system observers (connectivity, lifecycle, keyboard, and more) |
181+
| [ForgeStorage](https://github.com/stefanprojchev/ForgeStorage) | Type-safe persistence — key-value, file storage, and Keychain |
182+
| [ForgeBackgroundTasks](https://github.com/stefanprojchev/ForgeBackgroundTasks) | BGTaskScheduler registration, scheduling, and dispatch |
183+
| [ForgeLocation](https://github.com/stefanprojchev/ForgeLocation) | Location-based triggers — geofencing, significant changes, visits |
184+
| **ForgePush** | Push notification management — permissions, tokens, silent and visible routing |
185+
| [ForgeOrchestrator](https://github.com/stefanprojchev/ForgeOrchestrator) | Sequence, pipeline, and monitor orchestrators for iOS app flows |
186+
187+
## License
188+
189+
MIT License. See [LICENSE](LICENSE) for details.

Sources/ForgePush/ForgePush.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@_exported import ForgePushPermission
2+
@_exported import ForgePushToken
3+
@_exported import ForgeSilentPush
4+
@_exported import ForgeVisiblePush
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#if canImport(UIKit)
2+
import UIKit
3+
import UserNotifications
4+
5+
/// Standalone push notification permission management.
6+
///
7+
/// Wraps `UNUserNotificationCenter` for requesting and checking authorization.
8+
/// No observation — use ForgeObservers for status tracking.
9+
public struct PushPermission: Sendable {
10+
11+
// MARK: - Initialization
12+
13+
public init() {}
14+
15+
// MARK: - Implementation
16+
17+
/// Requests push notification authorization.
18+
/// - Parameter options: Authorization options. Defaults to alert, badge, and sound.
19+
/// - Returns: Whether the user granted permission.
20+
@discardableResult
21+
public func request(
22+
_ options: UNAuthorizationOptions = [.alert, .badge, .sound]
23+
) async throws -> Bool {
24+
try await UNUserNotificationCenter.current()
25+
.requestAuthorization(options: options)
26+
}
27+
28+
/// Returns the current authorization status.
29+
public func status() async -> UNAuthorizationStatus {
30+
await UNUserNotificationCenter.current()
31+
.notificationSettings()
32+
.authorizationStatus
33+
}
34+
35+
/// Opens the app's notification settings in Settings.app.
36+
@MainActor
37+
public func openSettings() {
38+
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
39+
UIApplication.shared.open(url)
40+
}
41+
}
42+
#endif
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#if canImport(UIKit)
2+
import UIKit
3+
4+
extension PushTokenManager {
5+
/// Triggers `UIApplication.shared.registerForRemoteNotifications()`.
6+
@MainActor
7+
public func registerForRemoteNotifications() {
8+
UIApplication.shared.registerForRemoteNotifications()
9+
}
10+
}
11+
#endif

0 commit comments

Comments
 (0)