Add Triton App

Signed-off-by: Otavio Cordeiro <otaviocc@users.noreply.github.com>
This commit is contained in:
Otávio 2025-12-15 12:07:17 +01:00 committed by Otavio Cordeiro
commit 3e878667a1
581 changed files with 59849 additions and 0 deletions

3
.env.sample Normal file
View file

@ -0,0 +1,3 @@
OMGLOL_CLIENT_ID=your_client_id_here
OMGLOL_CLIENT_SECRET=your_client_secret_here
OMGLOL_REDIRECT_URI=omglol://oauth/callback

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
ko_fi: otaviocc

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# xcode
*.pbxuser
*.perspective
*.perspectivev3
*.mode1v3
*.mode2v3
xcuserdata
build/*
DerivedData/
# *.xcworkspace/
# osx
.DS_Store
# vscode
.vscode/*
# Bundler
.bundle
# Carthage
Carthage
# CocoaPods
Pods/
Packages/*/Package.resolved
# Environment
.env

96
.swiftformat Normal file
View file

@ -0,0 +1,96 @@
# MARK: - Indentation and Spacing
--indent 4
--tab-width 4
--max-width 120
--wrap-arguments before-first
--wrap-parameters before-first
--wrap-collections before-first
--closing-paren balanced
--wrap-return-type never
# MARK: - Line Breaks and Spacing
--linebreaks lf
--trim-whitespace always
--trailing-commas never
--semicolons never
--operator-func spaced
--no-space-operators ...,..<
--ranges spaced
# MARK: - Braces and Brackets
--allman false
--else-position same-line
--guard-else auto
--ifdef indent
--indent-case false
--indent-strings false
--xcode-indentation enabled
# MARK: - Imports and Headers
--import-grouping testable-last
--mark-extensions always
--mark-types always
--extension-acl on-extension
--property-types inferred
--nil-init remove
# MARK: - Function and Method Formatting
--func-attributes preserve
--type-attributes preserve
--stored-var-attributes preserve
--computed-var-attributes preserve
--pattern-let hoist
--strip-unused-args closure-only
--self remove
--self-required UIViewController,UIView,NSViewController,NSView
# MARK: - Comments and Documentation
--doc-comments before-declarations
--header strip
--ifdef indent
# MARK: - SwiftUI and Modern Swift Features
--swift-version 6.0
--sort-swiftui-properties first-appearance-sort
--some-any true
--short-optionals always
# MARK: - Code Organization
--organize-types class,actor,struct,enum,extension,protocol
--organization-mode visibility
--class-threshold 0
--struct-threshold 0
--enum-threshold 0
--extension-threshold 0
# MARK: - Blank Lines and Spacing
# --disable blankLinesAtStartOfScope
--enable blankLinesAtStartOfScope
--disable blankLinesAtEndOfScope
--type-blank-lines insert
# MARK: - Exclusions
--exclude "**/.build/**"
--exclude "**/DerivedData/**"
--exclude "**/Pods/**"
--exclude "**/Carthage/**"
--exclude "**/.git/**"
--exclude "**/node_modules/**"
# MARK: - File Patterns
--symlinks ignore
# MARK: - Enhanced Modern Swift
--acronyms auto
# MARK: - Better Organization
--modifierorder override,public,internal,fileprivate,private,static,class,final,required,convenience,lazy,weak,unowned
# MARK: - Improved Consistency
--wrap-ternary before-operators
# MARK: - Additional Rules
--enable wrapMultilineStatementBraces
--enable preferKeyPath
--enable sortSwitchCases
--enable blankLinesBetweenImports

View file

@ -0,0 +1,59 @@
# ADR-001: Modular Architecture with Swift Package Manager
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
When designing the OMG application architecture, I wanted to establish a solid foundation that would support long-term maintainability and growth. I evaluated several approaches for structuring the codebase:
- **Workspace with multiple Xcode targets:** Traditional approach but requires manual dependency management
- **CocoaPods/Carthage frameworks:** Third-party dependency managers with additional tooling overhead
- **Swift Package Manager (SPM):** Native Swift solution with first-class Xcode integration
**Decision:**
I chose Swift Package Manager as the foundation for a modular architecture, organizing the codebase into discrete packages within a `Packages/` directory from the outset. Each package represents either a feature domain (Auth, Status, Account, etc.) or infrastructure concern (OMGAPI, DesignSystem, SessionService, etc.).
Key principles of this SPM-based architecture:
1. **Local packages:** All packages reside in `Packages/` directory within the repository, not as external dependencies
2. **Explicit dependencies:** Each `Package.swift` declares its dependencies, making relationships clear and enforceable
3. **Target-based layering:** Within packages, I use multiple targets (main module, Service, Repository, NetworkService, PersistenceService) to enforce layer boundaries
4. **Public API surfaces:** Packages expose only necessary APIs; internal implementation details remain private
5. **Independent testing:** Each package has its own test target, enabling isolated unit testing
6. **Shared utilities first:** Foundation packages (FoundationExtensions, Utilities) have no feature dependencies
**Consequences:**
### Positive
- **Faster incremental builds:** Xcode only rebuilds changed packages and their dependents
- **Clear dependency graph:** SPM enforces acyclic dependencies at compile time, preventing circular references
- **Better code organization:** Related code lives together in cohesive packages with clear purposes
- **Improved testability:** Individual packages can be tested in isolation without application overhead
- **Enforced architecture:** Package boundaries make it impossible to violate layering rules without explicit dependency changes
- **Native tooling:** SPM is built into Xcode and Swift, requiring no additional setup
- **Scalability:** The architecture naturally accommodates growth without major restructuring
### Negative
- **Initial setup overhead:** Creating package structure requires upfront planning before writing feature code
- **Xcode scheme proliferation:** Each package and target creates additional schemes (mitigated by hiding unnecessary schemes)
- **Cross-package refactoring:** Moving code between packages requires updating multiple `Package.swift` files
- **Build system quirks:** Occasional Xcode caching issues require clean builds or derived data deletion
### Neutral
- **Package granularity decisions:** Ongoing judgment calls about when to split or merge packages
- **Version management:** All packages version together with the main app (not independent versioning)
**Related Decisions:**
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Defines how packages relate to each other
- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Details package internal structure
**Notes:**
This modular approach was chosen from the beginning to establish clear boundaries and maintainability patterns from day one.

View file

@ -0,0 +1,89 @@
# ADR-002: Layered Architecture and Dependency Direction
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
When designing the OMG application, I wanted to establish clear architectural boundaries that would prevent common issues like tight coupling, circular dependencies, and difficulty testing. I needed a structure that would:
1. Separate concerns cleanly (UI, business logic, data access)
2. Make dependencies explicit and enforceable
3. Enable testing at each layer in isolation
4. Prevent accidental violations of architectural rules
**Decision:**
I adopted a strict layered architecture with enforced one-way dependency flow. The layers are:
```
Views
View Models
Repositories
Network & Persistence Services
Shared Services (OMGAPI, SessionService, DesignSystem)
Foundation Modules (FoundationExtensions, Utilities)
```
**Dependency Rules (Enforced):**
1. **UI → Repository (NOT → Services):** Views and ViewModels depend on Repository protocols, never directly on NetworkService or PersistenceService
2. **Repository → Services:** Repositories coordinate between NetworkService and PersistenceService layers
3. **Services → Shared Utilities:** Services depend only on infrastructure (OMGAPI, SessionService) and utilities
4. **No upward dependencies:** Lower layers cannot import higher layers
5. **Cross-cutting packages independent:** Infrastructure packages (DesignSystem, OMGAPI) do not depend on features
**Layer Responsibilities:**
- **Views (SwiftUI):** Presentation logic only, delegating actions to ViewModels
- **ViewModels (@Observable):** UI state management, coordinating user actions via Repository protocols
- **Repositories:** Domain logic, caching strategies, data coordination between network and persistence
- **NetworkService:** API communication, mapping remote payloads to DTOs (OMGAPI models)
- **PersistenceService:** Swift Data storage, local data management with domain or DTO representations
- **Shared Infrastructure:** Cross-cutting concerns (HTTP client, session management, UI components)
- **Foundation Modules:** Pure utilities with no domain knowledge
**How Dependencies are Enforced:**
SPM package dependencies in `Package.swift` files make violations impossible at compile time. For example:
- A View's package can depend on Repository but not NetworkService
- NetworkService cannot import Repository (would be rejected by SPM)
- Repositories are often `actor` types, ensuring thread-safe data operations
**Consequences:**
### Positive
- **Testability:** Each layer can be tested independently using protocol mocks/stubs
- **Compile-time safety:** Architectural violations are caught by the Swift compiler
- **Clear responsibilities:** Each layer has a well-defined purpose
- **Flexibility:** Implementations can be swapped without affecting higher layers (e.g., switching persistence strategies)
- **Reasoning:** Easy to understand where code belongs and how data flows
- **Concurrency safety:** Actor isolation at repository layer prevents data races
### Negative
- **Initial complexity:** New features require thinking about multiple layers
- **Boilerplate:** Protocol definitions and implementations add code volume
- **Cross-layer changes:** Some features require touching multiple layers sequentially
### Neutral
- **Layer granularity:** Ongoing decisions about when to introduce intermediate layers (e.g., Service layer between Repository and UI)
**Related Decisions:**
- [ADR-001: Modular Architecture with Swift Package Manager](ADR-001-modular-architecture-with-spm.md) - SPM enables enforcement
- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - How layers map to package targets
- [ADR-009: Protocol-First Repository and Service Boundaries](../Patterns/ADR-009-protocol-first-boundaries.md) - Testing and abstraction approach
- [ADR-012: DTO-Based Data Flow](../Data-Flow/ADR-012-dto-based-data-flow.md) - How data transforms across layers
**Notes:**
This strict layering was designed from the start to prevent architectural erosion over time. The one-way dependency flow ensures the codebase remains maintainable as it grows.

View file

@ -0,0 +1,98 @@
# ADR-003: Feature-Based Package Organization
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
With a modular architecture established via Swift Package Manager, I needed to define how to organize code within each package. The key questions were:
1. What constitutes a "feature" worthy of its own package?
2. How should layers be represented within a feature package?
3. What should be public vs. private in each package?
4. How do features integrate with the main application?
**Decision:**
I adopted a feature-based package organization where each domain feature (Auth, Status, Account, PURLs, Pastebin, etc.) is a separate package with multiple internal targets representing architectural layers.
**Package Structure Pattern:**
```
FeatureName/
├── Package.swift # Dependencies and targets
├── Sources/
│ ├── FeatureName/ # Main UI and app integration
│ │ ├── Views/ # All SwiftUI views
│ │ │ ├── App/ # Main feature view and view model
│ │ │ └── [Feature Areas]/ # Other views grouped by functionality
│ │ ├── Scenes/ # Private window scenes (WindowGroup)
│ │ ├── Factories/ # AppFactory with public integration methods
│ │ └── Environment/ # Feature environment and DI setup
│ ├── FeatureNameService/ # Business logic layer (optional)
│ ├── FeatureNameRepository/ # Data coordination
│ ├── FeatureNameNetworkService/ # API communication
│ └── FeatureNamePersistenceService/ # Swift Data storage
└── Tests/ # Unit tests for each layer
```
**Public API Surface:**
Each feature package exposes a minimal public API through its `AppFactory`:
1. `makeAppView()` - Creates the main feature view for display in the sidebar
2. `makeScene()` - Returns window scenes (private to the module) as `some Scene`
3. `makeSettingsView()` - Creates settings views (when applicable)
Everything else (scenes, view models, repositories, services) remains internal to the package.
**Feature Environment:**
Each feature has its own `Environment` struct that:
- Manages a `MicroContainer` for dependency resolution
- Registers all layer factories (NetworkService, PersistenceService, Repository)
- Exposes `viewModelFactory` and `modelContainer` properties
- Is passed to scenes which use it to build views
**Feature Categories:**
- **Domain Features:** Auth, Account, Status, Now, PURLs, Webpage, Pastebin, Weblog, Pics
- **Infrastructure Packages:** OMGAPI, DesignSystem, SessionService, AuthSession, Route, Sidebar
- **Utility Packages:** FoundationExtensions, Utilities
**Integration with Main App:**
The main application (`TritonApp`) uses `TritonEnvironment` to resolve all feature factories via MicroContainer. Scenes are now private within feature modules and receive the feature `Environment` to build views.
**Consequences:**
### Positive
- **Feature isolation:** Each feature is self-contained with clear boundaries
- **Parallel development:** Features can evolve independently without conflicts
- **Selective testing:** Can test individual features without loading others
- **Clear ownership:** Each feature package owns its complete vertical slice
- **Reduced coupling:** Features cannot access each other's internals
- **Minimal public API:** Only integration points are exposed, reducing breaking changes
### Negative
- **Package proliferation:** Many packages to navigate (mitigated by clear naming)
- **Cross-feature sharing:** Shared functionality must be extracted to infrastructure packages
- **Discovery:** Finding code requires knowing which feature it belongs to
### Neutral
- **Feature granularity:** Judgment calls about feature boundaries (e.g., should PURLs and Status be one package?)
- **Shared UI components:** Decision about when to move components to DesignSystem vs. keeping them in features
**Related Decisions:**
- [ADR-001: Modular Architecture with Swift Package Manager](ADR-001-modular-architecture-with-spm.md) - Enables package-based organization
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Layers within packages
- [ADR-010: AppFactory Pattern for Feature Integration](../Patterns/ADR-010-appfactory-pattern.md) - How features expose themselves
**Notes:**
This structure was designed from the start to support clean feature boundaries and vertical slice architecture. Each package represents a complete feature with all its layers.

View file

@ -0,0 +1,77 @@
# ADR-004: Migration from Combine to Async/Await
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
Initially, the OMG application used Combine throughout for asynchronous operations in services and repositories. As the codebase evolved and Swift's concurrency model matured, I needed to decide whether to continue with Combine or migrate to async/await.
Key considerations:
1. **Language evolution:** Swift's native async/await became the recommended approach for asynchronous code
2. **Complexity:** Combine introduced complexity with operators, publishers, and subscriptions that weren't always necessary
3. **Learning curve:** async/await is more straightforward and easier to reason about
4. **Ecosystem shift:** Apple's frameworks increasingly favor async/await over Combine
**Decision:**
I migrated the codebase from Combine to async/await for services and repositories. The migration focused on:
1. **Service Layer:** All repository and service methods use async/await for asynchronous operations
2. **Network calls:** API requests use async/await instead of Combine publishers
3. **Data operations:** Swift Data operations naturally work with async/await
**Where Async/Await is Used:**
- Repository protocols and implementations
- NetworkService methods
- PersistenceService data operations
- ViewModel methods that coordinate async operations
- Any I/O bound operations (network, disk, etc.)
**Where Combine Still Existed After This Migration:**
- UI layer (ViewModels) continued to use Combine with @Published properties and ObservableObject
- This was later migrated separately with the adoption of Swift's Observation framework (see ADR-005)
**Consequences:**
### Positive
- **Simpler code:** Async/await reads sequentially, easier to understand than Combine chains
- **Better error handling:** Standard try/catch instead of Combine's error handling
- **Reduced boilerplate:** No need to manage Cancellables, subscriptions, or AnyCancellable sets
- **Native Swift:** Language-level support means better tooling and compiler support
- **Debugging:** Stack traces are clearer, easier to step through async code
### Negative
- **Migration effort:** Requires rewriting existing Combine-based code
- **Lost operators:** Some Combine operators (debounce, throttle) need manual implementation
- **Breaking changes:** Function signatures change from publishers to async throws
### Neutral
- **Different mental model:** Moving from reactive streams to sequential async code
- **Pattern changes:** Some reactive patterns need rethinking in async/await terms
**Migration Strategy:**
The migration was done incrementally:
1. Started with repositories and services (deepest layers)
2. Updated repository methods to return async/await instead of publishers
3. ViewModels continued using Combine to consume these async methods
4. Removed Combine imports from service and repository layers
5. UI layer migration to Observation framework happened later (see ADR-005)
**Related Decisions:**
- [ADR-005: Adoption of Swift Observation Framework](ADR-005-adoption-of-swift-observation-framework.md) - Completed the migration away from Combine in UI layer
- [ADR-006: Swift Data over Core Data](ADR-006-swift-data-over-core-data.md) - Swift Data works naturally with async/await
**Notes:**
This migration aligns with Swift's evolution and Apple's recommended patterns. Async/await provides a cleaner, more maintainable foundation for asynchronous code in services and repositories. The UI layer's transition away from Combine happened separately with the Observation framework adoption.

View file

@ -0,0 +1,115 @@
# ADR-005: Adoption of Swift Observation Framework
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
After migrating services and repositories from Combine to async/await (ADR-004), the UI layer still used Combine's ObservableObject protocol with @Published properties for view-viewmodel binding. Apple introduced the Observation framework with the @Observable macro as a modern replacement.
Key considerations:
1. **Simpler syntax:** @Observable eliminates boilerplate (@Published, objectWillChange)
2. **Better performance:** Fine-grained observation tracks only accessed properties
3. **Reduced dependencies:** No need for Combine in view models
4. **Language-level support:** @Observable is a Swift macro, not a framework feature
5. **SwiftUI integration:** Native support in SwiftUI for @Observable types
**Decision:**
I migrated all view models from ObservableObject + @Published to @Observable. This completed the removal of Combine from the codebase.
**Migration Pattern:**
**Before (Combine):**
```swift
final class StatusViewModel: ObservableObject {
@Published var statuses: [Status] = []
@Published var isLoading = false
private var cancellables = Set<AnyCancellable>()
func loadStatuses() {
isLoading = true
repository.fetchStatuses()
.sink { [weak self] completion in
self?.isLoading = false
} receiveValue: { [weak self] statuses in
self?.statuses = statuses
}
.store(in: &cancellables)
}
}
```
**After (Observation):**
```swift
@Observable
final class StatusViewModel {
var statuses: [Status] = []
var isLoading = false
func loadStatuses() async {
isLoading = true
defer { isLoading = false }
do {
statuses = try await repository.fetchStatuses()
} catch {
// handle error
}
}
}
```
**Use of @ObservationIgnored:**
Properties marked with @ObservationIgnored don't trigger view updates:
- Dependencies (repositories, services) injected via initializer
- Computed properties
- Internal state that doesn't affect UI
- Constants and configuration
**Consequences:**
### Positive
- **Less boilerplate:** No @Published, no Combine imports, no AnyCancellable storage
- **Better performance:** SwiftUI only observes accessed properties, not the entire object
- **Clearer code:** Direct property access instead of publisher chains
- **Natural async integration:** Works seamlessly with async/await in repositories
- **Compile-time safety:** @Observable provides type-safe observation
- **Removed Combine dependency:** UI layer no longer needs Combine framework
### Negative
- **Migration effort:** Requires rewriting all view models
- **Learning curve:** Understanding when to use @ObservationIgnored
- **Breaking changes:** View models are no longer ObservableObject instances
### Neutral
- **Different patterns:** Some Combine patterns (like debouncing) need alternative implementations
- **Observation scope:** Need to understand observation tracking to avoid over-observation
**Migration Strategy:**
The migration was done incrementally:
1. Started with simpler view models with fewer properties
2. Replaced ObservableObject conformance with @Observable macro
3. Removed @Published from properties
4. Converted Combine publisher chains to async/await calls
5. Removed AnyCancellable storage
6. Added @ObservationIgnored to dependencies and computed properties
7. Updated views to work with @Observable (mostly automatic)
**Related Decisions:**
- [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md) - Repositories already using async/await enabled smooth integration
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - ViewModels remain in UI layer with same responsibilities
**Notes:**
This migration completed the transition away from Combine throughout the entire codebase. The Observation framework provides a modern, performant, and simpler approach to reactive UI updates in SwiftUI.

View file

@ -0,0 +1,110 @@
# ADR-006: Swift Data over Core Data
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
The OMG application initially used Core Data for persistence. However, as the application evolved, I realized that for a simple application with straightforward data models, Core Data's boilerplate was excessive:
- `.xcdatamodeld` files and data model editors
- `NSManagedObject` subclasses with Objective-C runtime requirements
- `NSFetchRequest` with string-based predicates
- Context management and merging complexities
- Boilerplate that outweighed the actual business logic
When Apple introduced Swift Data, it offered a modern alternative that matched the application's needs without the overhead.
**Decision:**
I migrated from Core Data to Swift Data for all persistence in the OMG application. The simpler API and reduced boilerplate were a better fit for this application's straightforward data models.
**Key Implementation Patterns:**
1. **@Model macro:** Domain models or persistence models marked with @Model
2. **ModelContainer:** Each feature's environment manages its own ModelContainer
3. **ModelContext:** Used in PersistenceService implementations for CRUD operations
4. **Actor isolation:** PersistenceService implementations often use `actor` for thread safety
5. **Query patterns:** Repository layer queries through PersistenceService protocols
**Example Structure:**
```swift
// Model definition
@Model
final class StatusEntity {
var id: String
var content: String
var createdAt: Date
init(id: String, content: String, createdAt: Date) {
self.id = id
self.content = content
self.createdAt = createdAt
}
}
// PersistenceService usage
actor StatusPersistenceService {
private let modelContext: ModelContext
func fetchStatuses() async throws -> [StatusEntity] {
let descriptor = FetchDescriptor<StatusEntity>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
return try modelContext.fetch(descriptor)
}
}
```
**Consequences:**
### Positive
- **Dramatically less boilerplate:** No .xcdatamodeld files, NSEntityDescription, or NSManagedObject subclasses
- **Type safety:** Compile-time checking instead of runtime string-based queries
- **Modern Swift:** Uses macros, property wrappers, and Swift's type system
- **Natural async:** Swift Data works seamlessly with async/await
- **Simpler relationships:** Relationships defined as regular Swift properties
- **Better fit for simple models:** Complexity matches actual needs
- **Predictable behavior:** Clearer mental model than Core Data's context management
### Negative
- **Migration effort:** Required rewriting persistence layer and data models
- **Data migration:** Had to handle Core Data to Swift Data data migration
- **Maturity:** Swift Data is newer with fewer resources than Core Data
- **Feature parity:** Some advanced Core Data features not available (not needed for this app)
### Neutral
- **Learning investment:** Different from Core Data, but simpler overall
- **Platform requirements:** Requires recent OS versions (acceptable tradeoff)
**Migration Strategy:**
The migration from Core Data to Swift Data involved:
1. Redefining models using @Model macro instead of NSManagedObject
2. Replacing NSFetchRequest with FetchDescriptor
3. Converting NSPredicate string-based queries to Swift-native predicates
4. Updating PersistenceService implementations to use ModelContext
5. Migrating existing Core Data stores to Swift Data (if needed)
6. Removing .xcdatamodeld files and Core Data stack setup
**Integration with Architecture:**
- **PersistenceService layer:** Each feature has a PersistenceService target that uses Swift Data
- **Repository coordination:** Repositories coordinate between NetworkService and PersistenceService
- **ModelContainer management:** Feature environments expose modelContainer for dependency injection
- **Actor isolation:** PersistenceService implementations use `actor` for safe concurrent access
**Related Decisions:**
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Swift Data lives in PersistenceService layer
- [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md) - Swift Data naturally supports async/await
**Notes:**
For a simple application with straightforward data models, Swift Data's reduced boilerplate was a significant improvement. The migration was worthwhile to eliminate Core Data's complexity that wasn't justified by the application's actual needs.

View file

@ -0,0 +1,97 @@
# ADR-007: MicroClient for HTTP Communication
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
When designing the networking layer for the OMG application, I needed to choose an HTTP client library. The options included:
1. **URLSession directly:** Apple's native networking, but requires significant boilerplate
2. **Alamofire:** Popular third-party library with extensive features
3. **MicroClient:** Lightweight Swift-first HTTP client I built on top of URLSession
Key considerations:
- **Simplicity:** Needed clean API for straightforward HTTP requests
- **Modern Swift:** Should work naturally with async/await
- **Type safety:** Compile-time safety for requests and responses
- **Control:** Ability to customize exactly for this application's needs
- **Boilerplate reduction:** Less code than raw URLSession
**Decision:**
I chose to build and use MicroClient as the HTTP communication library for the OMG application. It provides a clean, type-safe API built on top of URLSession without the complexity of larger networking frameworks, tailored specifically to my needs.
**Key Implementation Patterns:**
1. **OMGAPIFactory:** Central factory for creating HTTP requests using MicroClient's `NetworkRequest`
2. **Request/Response models:** DTOs defined in OMGAPI package
3. **NetworkService layer:** Each feature's NetworkService uses MicroClient
4. **Async/await:** All network calls use async/await for clean asynchronous code
5. **Interceptors:** Authentication and common headers handled via MicroClient interceptors
**Example Usage:**
```swift
// In OMGAPIFactory - creates typed requests
extension OMGAPIFactory {
static func makeAllStatusesRequest() -> NetworkRequest<VoidRequest, StatuslogResponse> {
.init(
path: "/statuslog",
method: .get
)
}
}
// In NetworkService - executes requests
actor StatusNetworkService {
private let client: MicroClient
func fetchStatuses() async throws -> StatuslogResponse {
let request = OMGAPIFactory.makeAllStatusesRequest()
return try await client.run(request)
}
}
```
**Consequences:**
### Positive
- **Clean API:** Simpler than raw URLSession, focused on actual needs
- **Type safety:** Request and response types checked at compile time via `NetworkRequest<Request, Response>`
- **Async/await native:** Built for modern Swift concurrency
- **Lightweight:** Minimal dependency footprint
- **Full control:** Can evolve the library alongside the application
- **Tailored design:** API shaped exactly for this application's patterns
- **Flexible:** Can add interceptors for auth, logging, etc.
### Negative
- **Maintenance responsibility:** I maintain the library
- **Documentation:** Need to document patterns for my future reference
- **External use consideration:** Library evolved alongside this specific application
### Neutral
- **Dependency management:** External dependency, but under my control
- **Feature scope:** Only implements features needed by this application
**Integration with Architecture:**
- **OMGAPI package:** Contains OMGAPIFactory and all request/response DTOs
- **NetworkService layer:** Each feature's NetworkService depends on OMGAPI and uses MicroClient
- **Repository coordination:** Repositories consume NetworkService protocols
- **Error handling:** Network errors handled at repository boundary, mapped to domain errors
**Related Decisions:**
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - MicroClient used in NetworkService layer
- [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md) - MicroClient built with async/await support
- [ADR-012: DTO-Based Data Flow](../Data-Flow/ADR-012-dto-based-data-flow.md) - Request/response models in OMGAPI
**Notes:**
Building MicroClient gave me full control over the networking layer's API design and allowed me to create a library that fits perfectly with the application's architecture and patterns. The `NetworkRequest<Request, Response>` type provides compile-time safety for API calls.

View file

@ -0,0 +1,125 @@
# ADR-008: MicroContainer for Dependency Injection
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
With a modular architecture using multiple packages, I needed a dependency injection solution to wire up the application. The options included:
1. **Manual dependency injection:** Pass dependencies through initializers (simple but verbose)
2. **SwiftUI @Environment:** Good for view-level DI but limited to SwiftUI context
3. **Third-party DI frameworks:** Swinject, Resolver, etc. (feature-rich but complex)
4. **MicroContainer:** Lightweight DI container I built for this purpose
Key considerations:
- **Simplicity:** Needed straightforward registration and resolution
- **Type safety:** Compile-time safety where possible
- **Factory pattern support:** Factories are the primary integration pattern
- **Lifecycle management:** Support for singleton (.static) vs transient instances
- **Minimal overhead:** Lightweight solution without complex features
**Decision:**
I chose to build and use MicroContainer (exposed as `DependencyContainer`) as the dependency injection solution for the OMG application. It provides a simple, type-safe API for registering and resolving dependencies, tailored to the application's factory-based architecture.
**Key Implementation Patterns:**
1. **TritonEnvironment:** Main app environment manages root `DependencyContainer`
2. **Registration:** Dependencies registered with type, allocation strategy, and factory closure
3. **Resolution:** Simple `container.resolve()` with type inference
4. **Static allocation:** Singleton instances using `.static` allocation
5. **Dependency chains:** Container passed to factory closures for resolving dependencies
**Example Usage:**
```swift
// Main environment setup
struct TritonEnvironment: TritonEnvironmentProtocol {
private let container = DependencyContainer()
// Computed properties resolve from container
var authSessionService: any AuthSessionServiceProtocol { container.resolve() }
var statusAppFactory: StatusAppFactory { container.resolve() }
init() {
// Register core service with singleton allocation
container.register(
type: (any AuthSessionServiceProtocol).self,
allocation: .static
) { _ in
authSessionServiceFactory.makeAuthSessionService()
}
// Register with dependency resolution
container.register(
type: (any NetworkClientProtocol).self,
allocation: .static
) { container in
let authSessionService = container.resolve() as any AuthSessionServiceProtocol
return networkClient.makeOMGAPIClient(
authTokenProvider: {
await authSessionService.accessToken
}
)
}
// Register feature factory
container.register(
type: StatusAppFactory.self,
allocation: .static
) { container in
StatusAppFactory(
sessionService: container.resolve(),
authSessionService: container.resolve(),
networkClient: container.resolve()
)
}
}
}
```
**Consequences:**
### Positive
- **Simple API:** Easy to understand registration and resolution
- **Type safety:** Type-based resolution with compiler support
- **Lightweight:** Minimal overhead and complexity
- **Full control:** Can evolve alongside the application's needs
- **Factory-friendly:** Natural fit for factory-based architecture
- **Allocation control:** Explicit `.static` for singletons
- **Testability:** Easy to register mock implementations for testing
### Negative
- **Maintenance responsibility:** I maintain the library
- **Limited features:** No advanced DI features (property injection, circular dependency detection, etc.)
- **Manual registration:** Need to explicitly register all dependencies
- **No automatic cleanup:** Need to manage lifecycles manually
### Neutral
- **Learning curve:** Need to understand container patterns
- **Feature scope:** Only implements features needed by this application
- **Dependency management:** External dependency, but under my control
**Integration with Architecture:**
- **TritonEnvironment:** Root environment in main app with `DependencyContainer`
- **Registration pattern:** Init methods register all dependencies with allocation strategy
- **Resolution pattern:** Computed properties use `container.resolve()` for lazy access
- **Factory dependencies:** Factories receive resolved dependencies via initializers
- **Testing:** Test environments can register mock implementations
**Related Decisions:**
- [ADR-001: Modular Architecture with Swift Package Manager](ADR-001-modular-architecture-with-spm.md) - Container wires together modular packages
- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Each feature's AppFactory resolved from container
- [ADR-010: AppFactory Pattern for Feature Integration](../Patterns/ADR-010-appfactory-pattern.md) - Factories use container for dependency resolution
**Notes:**
Building MicroContainer gave me a dependency injection solution that fits perfectly with the factory-based architecture. The `DependencyContainer` provides just enough DI functionality with explicit allocation control and type-safe resolution.

View file

@ -0,0 +1,172 @@
# ADR-009: Protocol-First Repository and Service Boundaries
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
When designing the architecture for repositories and services, I needed to decide how to define their boundaries and contracts. Key considerations included:
1. **Abstraction:** Enable testing and flexibility without tight coupling to implementations
2. **Documentation:** Clear contracts for what each layer provides
3. **Dependency injection:** Support for swapping implementations
4. **Type safety:** Compile-time guarantees about capabilities
5. **Sendable conformance:** Support for Swift concurrency and actor isolation
The options were:
- **Concrete types only:** Simple but inflexible, hard to test
- **Protocols for everything:** Maximum flexibility but adds boilerplate
- **Protocol-first boundaries:** Protocols at layer boundaries, concrete types internally
**Decision:**
I adopted a protocol-first approach for repositories and services, where:
1. **Public protocols define contracts** - Each repository and service has a public protocol
2. **Concrete implementations are internal/actor-based** - Implementations use `actor` for thread safety
3. **Only protocols are documented** - DocC documentation focuses on public protocol contracts
4. **Protocols are Sendable** - Enable safe usage across actor boundaries
5. **Implementations remain undocumented** - Internal details don't need public documentation
**Implementation Pattern:**
```swift
/// Public protocol with comprehensive documentation
public protocol PURLsNetworkServiceProtocol: AnyObject, Sendable {
/// Provides a stream of PURL collection updates.
///
/// This method returns an AsyncStream that emits arrays of `PURLResponse` objects
/// whenever PURL collections are fetched or modified. This enables reactive UI updates
/// and real-time synchronization between network operations and local storage.
///
/// - Returns: An `AsyncStream<[PURLResponse]>` that emits PURL collection updates.
func purlsStream() -> AsyncStream<[PURLResponse]>
/// Fetches all PURLs for a specific address and emits them through the stream.
///
/// - Parameter address: The address to fetch PURLs for.
/// - Throws: Network errors, API errors, or decoding errors if the fetch operation fails.
func fetchPURLs(for address: String) async throws
func addPURL(address: String, name: String, url: String) async throws
func deletePURL(address: String, name: String) async throws
}
/// Actor implementation without public documentation
actor PURLsNetworkService: PURLsNetworkServiceProtocol {
private let networkClient: NetworkClientProtocol
private let purlsStreamContinuation: AsyncStream<[PURLResponse]>.Continuation
init(networkClient: NetworkClientProtocol) {
self.networkClient = networkClient
// Implementation details...
}
func fetchPURLs(for address: String) async throws {
// Implementation...
}
// Other methods...
}
```
**Protocol Naming Convention:**
- Repositories: `FeatureNameRepositoryProtocol` (e.g., `PURLsRepositoryProtocol`)
- Network Services: `FeatureNameNetworkServiceProtocol` (e.g., `PURLsNetworkServiceProtocol`)
- Persistence Services: `FeatureNamePersistenceServiceProtocol`
- Implementations drop the `Protocol` suffix (e.g., `PURLsRepository`)
**Sendable Conformance:**
Protocols marked as `Sendable` to enable:
- Safe passing across actor boundaries
- Usage in concurrent contexts
- Compliance with Swift 6 strict concurrency
```swift
public protocol PURLsRepositoryProtocol: Sendable {
// Methods...
}
```
**Documentation Strategy:**
1. **Protocols:** Fully documented with DocC comments
- What the method does
- Parameters and return values
- Error conditions
- Usage examples when helpful
2. **Implementations:** Minimal or no documentation
- Implementation details are internal
- Comments only for complex internal logic
- Focus on maintainability, not external API
**Why Not Document Implementations:**
- Implementations are `actor` or internal types, not part of public API
- Keeps documentation focused on contracts, not implementation details
- Reduces maintenance burden when implementations change
- Protocol documentation is the source of truth
**Consequences:**
### Positive
- **Testability:** Easy to create mock implementations for testing
- **Flexibility:** Can swap implementations without affecting consumers
- **Clear contracts:** Protocol defines exactly what each layer provides
- **Documentation clarity:** Protocol docs are single source of truth
- **Type safety:** Compiler enforces protocol conformance
- **Sendable safety:** Protocols ensure safe concurrent usage
- **Reduced maintenance:** Don't need to keep implementation docs in sync
### Negative
- **Boilerplate:** Need to define both protocol and implementation
- **Indirection:** One extra layer between usage and implementation
- **Protocol proliferation:** Many protocols throughout the codebase
### Neutral
- **Naming conventions:** Need consistent protocol naming patterns
- **Protocol granularity:** Ongoing decisions about protocol size and scope
**Integration with Architecture:**
- **Repository layer:** Repositories depend on service protocols, not implementations
- **Dependency injection:** Factories register protocol types in DependencyContainer
- **View models:** Depend on repository protocols
- **Testing:** Mock objects conform to protocols
```swift
// Repository depends on service protocols
actor PURLsRepository: PURLsRepositoryProtocol {
private let networkService: any PURLsNetworkServiceProtocol
private let persistenceService: any PURLsPersistenceServiceProtocol
// ...
}
// Registered by protocol type
container.register(
type: (any PURLsRepositoryProtocol).self,
allocation: .static
) { container in
PURLsRepository(
networkService: container.resolve(),
persistenceService: container.resolve()
)
}
```
**Related Decisions:**
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Protocols define layer boundaries
- [ADR-008: MicroContainer for Dependency Injection](ADR-008-microcontainer-for-dependency-injection.md) - Protocols registered in container
- [ADR-011: Actor Isolation for Repository and Service Concurrency](ADR-011-actor-isolation-for-repository-concurrency.md) - Implementations are actors
**Notes:**
The protocol-first approach strikes the right balance between abstraction and simplicity. By documenting only protocols, I maintain clear contracts while keeping implementation details internal and flexible. The Sendable conformance ensures protocols work seamlessly with Swift's actor-based concurrency model.

View file

@ -0,0 +1,141 @@
# ADR-010: AppFactory Pattern for Feature Integration
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
With a modular architecture where each feature is a separate package, I needed a consistent way to integrate features into the main application. The key challenges were:
1. **Encapsulation:** Feature internals (view models, repositories, services) should remain private
2. **Dependency injection:** Features need various dependencies from the main app
3. **Multiple entry points:** Features may provide main views, scenes, and settings views
4. **SwiftUI integration:** Need to provide both views and scenes (WindowGroup, etc.)
5. **Testability:** Integration points should be mockable
**Decision:**
I adopted the AppFactory pattern where each feature package exposes a single public factory class with three standardized methods:
1. **`makeAppView()`** - Creates the main feature view for display in the sidebar or main content area
2. **`makeScene()`** - Returns window scenes (WindowGroup) private to the module
3. **`makeSettingsView()`** - Creates settings views when applicable (optional)
Factories receive dependencies via their initializer and internally manage a feature-specific Environment that handles further dependency resolution.
**Implementation Pattern:**
```swift
public final class StatusAppFactory {
private let environment: StatusEnvironment
public init(
sessionService: any SessionServiceProtocol,
authSessionService: any AuthSessionServiceProtocol,
networkClient: NetworkClientProtocol
) {
environment = .init(
sessionService: sessionService,
authSessionService: authSessionService,
networkClient: networkClient
)
}
@MainActor
@ViewBuilder
public func makeAppView() -> some View {
let viewModel = environment.viewModelFactory
.makeStatusAppViewModel()
StatusApp(viewModel: viewModel)
.environment(\.viewModelFactory, environment.viewModelFactory)
.modelContainer(environment.modelContainer)
}
@MainActor
@ViewBuilder
public func makeSettingsView() -> some View {
let viewModel = environment.viewModelFactory
.makeStatusSettingsViewModel()
StatusSettingsView(viewModel: viewModel)
.modelContainer(environment.modelContainer)
}
@MainActor
public func makeScene() -> some Scene {
ComposeStatusScene(environment: environment)
}
}
```
**Integration with TritonEnvironment:**
```swift
struct TritonEnvironment: TritonEnvironmentProtocol {
private let container = DependencyContainer()
var statusAppFactory: StatusAppFactory { container.resolve() }
init() {
container.register(
type: StatusAppFactory.self,
allocation: .static
) { container in
StatusAppFactory(
sessionService: container.resolve(),
authSessionService: container.resolve(),
networkClient: container.resolve()
)
}
}
}
```
**Why Factories Over Direct Instantiation:**
1. **Encapsulation:** Keeps view models, repositories, and internal dependencies private
2. **Single public interface:** Only the factory is public, everything else is internal
3. **Dependency coordination:** Factory manages complex dependency graphs internally
4. **Consistent integration:** All features integrate the same way
5. **Testability:** Can mock factories for testing main app integration
6. **Lazy initialization:** Features only initialize when actually used
**Consequences:**
### Positive
- **Clear boundaries:** Features expose minimal public API surface
- **Consistent pattern:** All features integrate identically
- **Dependency management:** Complex graphs handled internally by each feature
- **Flexibility:** Features can evolve internals without affecting main app
- **Testability:** Integration points are mockable
- **SwiftUI native:** Returns proper SwiftUI types (some View, some Scene)
### Negative
- **Boilerplate:** Each feature needs a factory class
- **Indirect access:** Can't directly instantiate feature components
- **Factory proliferation:** One factory per feature adds code
### Neutral
- **Learning curve:** Team members need to understand factory pattern
- **Factory responsibilities:** Ongoing decisions about what belongs in factory vs environment
**Method Contracts:**
- **`makeAppView()`** - REQUIRED: Must return main feature view, marked @MainActor
- **`makeScene()`** - OPTIONAL: Returns scenes for auxiliary windows, marked @MainActor
- **`makeSettingsView()`** - OPTIONAL: Returns settings view when feature has preferences, marked @MainActor
**Related Decisions:**
- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Factories are part of feature structure
- [ADR-008: MicroContainer for Dependency Injection](ADR-008-microcontainer-for-dependency-injection.md) - Factories registered in container
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Factories are integration layer
**Notes:**
The AppFactory pattern provides a clean, consistent way to integrate modular features while maintaining strong encapsulation. It's the primary public interface for each feature package.

View file

@ -0,0 +1,163 @@
# ADR-011: Actor Isolation for Repository and Service Concurrency
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
With the adoption of async/await and Swift concurrency, I needed to decide how to handle thread safety and concurrent access in the service and repository layers. These layers coordinate async operations, manage streaming tasks, and may maintain internal state.
Key considerations:
1. **Thread safety:** Services and repositories may be accessed from multiple concurrent contexts
2. **Mutable state:** Streaming tasks, caches, and coordination state need protection
3. **Data races:** Swift 6 strict concurrency requires proper isolation
4. **Performance:** Don't want excessive synchronization overhead
5. **API ergonomics:** Should be easy to use with async/await
The options were:
- **No isolation:** Unsafe, would lead to data races
- **Manual locking:** Error-prone and verbose (locks, queues)
- **@MainActor:** Simple but forces all operations to main thread
- **actor:** Automatic serialization with async/await integration
**Decision:**
I chose to implement services and repositories as `actor` types. Actors provide automatic serialization of mutable state access while maintaining clean async/await APIs. This ensures thread safety without manual synchronization code.
**Implementation Pattern:**
```swift
public protocol PURLsRepositoryProtocol: Sendable {
var purlsContainer: ModelContainer { get }
func fetchPURLs() async throws
func addPURL(address: String, name: String, url: String) async throws
func deletePURL(address: String, name: String) async throws
}
actor PURLsRepository: PURLsRepositoryProtocol {
// Actor-isolated mutable state
private let networkService: any PURLsNetworkServiceProtocol
private let persistenceService: any PURLsPersistenceServiceProtocol
private var streamTask: Task<Void, Never>?
// Nonisolated for synchronous access to immutable/Sendable values
nonisolated var purlsContainer: ModelContainer {
persistenceService.container
}
init(
networkService: any PURLsNetworkServiceProtocol,
persistenceService: PURLsPersistenceServiceProtocol,
authSessionService: any AuthSessionServiceProtocol,
sessionService: any SessionServiceProtocol
) {
self.networkService = networkService
self.persistenceService = persistenceService
Task {
await startPURLsSync()
}
}
func fetchPURLs() async throws {
// Actor-isolated method, automatically serialized
try await networkService.fetchPURLs(for: current)
}
private func startPURLsSync() {
// Manages mutable streamTask safely within actor
streamTask = Task { [weak self] in
guard let self else { return }
for await purls in networkService.purlsStream() {
// Process stream...
}
}
}
}
```
**When to Use `actor` vs `@MainActor`:**
### Use `actor` for:
- **Repositories** - Coordinate async operations, manage streaming tasks, cache data
- **NetworkServices** - Handle network requests and response processing
- **PersistenceServices** - Manage Swift Data operations and storage
- **Background coordination** - Any service that doesn't need main thread
- **Mutable state** - Types that need to protect mutable internal state
### Use `@MainActor` for:
- **ViewModels** - Need to update UI on main thread
- **View code** - SwiftUI views and their direct dependencies
- **UI coordination** - Types that only work with UI state
### Don't isolate:
- **Pure protocols** - Protocol definitions should be Sendable, not isolated
- **Immutable types** - Structs with only immutable properties
- **Stateless utilities** - Pure functions and stateless helpers
**Nonisolated Access:**
Use `nonisolated` for properties that:
- Return Sendable values (ModelContainer, etc.)
- Are computed from actor-isolated state but safe to access directly
- Need synchronous access from outside the actor
```swift
nonisolated var purlsContainer: ModelContainer {
persistenceService.container
}
```
**Data Safety Guarantees:**
1. **Serialized access:** All actor methods execute serially, preventing data races
2. **Task management:** Internal tasks (streaming, background work) are safely managed
3. **State isolation:** Mutable state is protected within actor boundary
4. **Sendable conformance:** Protocols marked Sendable ensure safe cross-actor usage
**Performance Considerations:**
### Benefits:
- **Fine-grained concurrency:** Actors suspend rather than block
- **No lock overhead:** Runtime manages scheduling efficiently
- **Natural async:** Integrates seamlessly with async/await
- **Background execution:** Services and repositories don't block main thread
### Tradeoffs:
- **Suspension points:** Each await on actor method is a potential suspension
- **Sequential execution:** Actor methods run serially (by design for safety)
- **Actor hopping:** Calls between different actors involve context switches
**Consequences:**
### Positive
- **Automatic thread safety:** No manual locks or synchronization code
- **Data race prevention:** Compiler enforces safe concurrent access
- **Clean API:** Async/await usage feels natural
- **Mutable state protection:** Internal state safely managed
- **Swift 6 ready:** Strict concurrency checking passes
- **Off main thread:** Services and repositories don't block UI
### Negative
- **Learning curve:** Understanding actor isolation and suspension points
- **Performance characteristics:** Need to understand when suspension occurs
- **Debugging complexity:** Async context switches can complicate debugging
### Neutral
- **Actor reentrancy:** Need to be aware of reentrancy between suspension points
- **Sendable requirements:** Types crossing actor boundaries must be Sendable
**Related Decisions:**
- [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md) - Actors work naturally with async/await
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Actor isolation at service and repository layers
- [ADR-005: Adoption of Swift Observation Framework](ADR-005-adoption-of-swift-observation-framework.md) - ViewModels use @MainActor, not actor
**Notes:**
Actor isolation for services and repositories provides the right balance of thread safety and performance. It allows these layers to safely manage concurrent operations like networking, persistence, streaming, and caching while maintaining clean async/await APIs for consumers. Using actors throughout these layers ensures UI work stays on the main thread while data operations execute safely in the background.

View file

@ -0,0 +1,163 @@
# ADR-012: DTO-Based Data Flow
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
When designing data flow through the application layers, I needed to decide how data should be represented and transformed as it moves from the network to the UI. Key considerations included:
1. **API coupling:** Keep API response formats separate from domain models
2. **Type safety:** Ensure compile-time safety at layer boundaries
3. **Mapping responsibility:** Clear ownership of data transformation
4. **Flexibility:** Enable API changes without affecting domain logic
5. **Testability:** Easy to create test fixtures at each layer
**Decision:**
I adopted a DTO (Data Transfer Object) based approach where data flows through distinct representations at each layer:
```
Network API
DTOs (OMGAPI package) ← Codable, API-shaped structures
Repository ← Maps DTOs to domain models or persistence models
Domain Models / Persistence Models ← SwiftData @Model or view-friendly structs
ViewModels / Views
```
**Layer Responsibilities:**
1. **OMGAPI Package (DTOs)**
- Contains all request and response models
- Matches API structure exactly (Codable)
- Marked `Sendable` for concurrency safety
- Public, shared across features
2. **NetworkService Layer**
- Fetches data from API
- Decodes responses into DTOs
- No mapping logic - returns DTOs as-is
3. **Repository Layer**
- Maps DTOs to domain/persistence models
- Applies business logic during transformation
- Coordinates between network DTOs and local storage
4. **PersistenceService Layer**
- Stores/retrieves domain or DTO representations
- Uses SwiftData @Model types for persistence
**Example Flow:**
```swift
// 1. DTO in OMGAPI package
public struct PURLsResponse: Decodable, Sendable {
public let request: RequestResponse
public let response: Response
public struct Response: Decodable, Sendable {
public let message: String
public let purls: [PURLResponse]
}
}
public extension PURLsResponse.Response {
struct PURLResponse: Decodable, Sendable {
public let name: String
public let url: URL
public let counter: Int?
}
}
// 2. NetworkService returns DTOs
actor PURLsNetworkService: PURLsNetworkServiceProtocol {
func fetchPURLs(for address: String) async throws {
let request = OMGAPIFactory.makeAllPURLsRequest(for: address)
let response: PURLsResponse = try await client.run(request)
// Emit DTOs through stream
continuation.yield(response.response.purls)
}
}
// 3. Repository maps to persistence model
actor PURLsRepository: PURLsRepositoryProtocol {
private func startPURLsSync() {
for await purls in networkService.purlsStream() {
let storablePurls = purls.map { purlResponse in
StorablePURL(
address: current,
purlResponse: purlResponse // Map DTO to @Model
)
}
try await persistenceService.storePURLs(purls: storablePurls)
}
}
}
// 4. SwiftData persistence model
@Model
final class StorablePURL {
var address: String
var name: String
var url: URL
var counter: Int?
init(address: String, purlResponse: PURLResponse) {
self.address = address
self.name = purlResponse.name
self.url = purlResponse.url
self.counter = purlResponse.counter
}
}
```
**Mapping Patterns:**
- **DTO → @Model:** Repository maps during persistence operations
- **@Model → View Models:** SwiftData queries provide models directly to views
- **DTO → Domain Structs:** Some features use lightweight structs instead of @Model
- **No direct DTO to View:** Views never see DTOs, only domain models
**Consequences:**
### Positive
- **Decoupling:** API changes don't ripple through all layers
- **Type safety:** Each layer has appropriate types for its purpose
- **Clear boundaries:** Transformation happens at well-defined points
- **Testability:** Easy to create fixtures at each layer
- **Sendable safety:** DTOs are Sendable, enabling actor isolation
- **Flexibility:** Can change domain models without affecting API contract
### Negative
- **Mapping boilerplate:** Need code to transform between representations
- **Multiple representations:** Same data exists in different forms
- **Memory overhead:** Temporary DTO objects during transformation
### Neutral
- **Mapping complexity:** Simple for straightforward cases, more complex for nested structures
- **Performance impact:** Mapping cost is generally negligible for typical data sizes
**Why Not Direct API Models:**
Alternatives considered:
- **Use DTOs everywhere:** Couples domain logic to API structure
- **Use domain models for network:** Forces Codable on domain types
- **Manual JSON parsing:** Error-prone and verbose
**Related Decisions:**
- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - DTOs flow through layers
- [ADR-006: Swift Data over Core Data](ADR-006-swift-data-over-core-data.md) - @Model types are mapping targets
- [ADR-007: MicroClient for HTTP Communication](ADR-007-microclient-for-http-communication.md) - NetworkRequest uses DTOs for response types
**Notes:**
The DTO-based approach provides clean separation between API contracts and domain models. While it requires mapping code, the benefits of decoupling and type safety outweigh the boilerplate cost. The OMGAPI package serves as the single source of truth for API data structures.

View file

@ -0,0 +1,159 @@
# ADR-013: Repository Caching and Streaming Patterns
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
Repositories coordinate between NetworkService and PersistenceService layers, managing how data flows between remote APIs and local storage. I needed to establish patterns for:
1. **Data freshness:** When to fetch from network vs local cache
2. **Real-time updates:** How to keep UI synchronized with data changes
3. **Offline support:** Enable viewing cached data without network
4. **Conflict resolution:** Handle discrepancies between local and remote data
5. **User experience:** Minimize loading states and perceived latency
**Decision:**
I adopted a streaming-based caching pattern where repositories use AsyncStream to coordinate continuous data synchronization between network and persistence layers.
**Key Patterns:**
### 1. Stream-Based Synchronization
Repositories set up AsyncStream listeners that automatically persist incoming data:
```swift
actor PURLsRepository: PURLsRepositoryProtocol {
private var streamTask: Task<Void, Never>?
init(...) {
Task {
await startPURLsSync()
}
}
private func startPURLsSync() {
streamTask = Task { [weak self] in
guard let self else { return }
// Listen to network service stream
for await purls in networkService.purlsStream() {
guard !Task.isCancelled else { break }
// Automatically persist incoming data
let storablePurls = purls.map { purlResponse in
StorablePURL(address: current, purlResponse: purlResponse)
}
try await persistenceService.storePURLs(purls: storablePurls)
}
}
}
}
```
### 2. Network Service Streams
NetworkServices emit data through AsyncStream, enabling reactive updates:
```swift
actor PURLsNetworkService: PURLsNetworkServiceProtocol {
private let purlsStreamContinuation: AsyncStream<[PURLResponse]>.Continuation
func fetchPURLs(for address: String) async throws {
let request = OMGAPIFactory.makeAllPURLsRequest(for: address)
let response: PURLsResponse = try await client.run(request)
// Emit through stream for automatic caching
continuation.yield(response.response.purls)
}
func purlsStream() -> AsyncStream<[PURLResponse]> {
purlsAsyncStream
}
}
```
### 3. SwiftData as Cache
PersistenceServices use SwiftData's ModelContainer, which views can query directly:
```swift
public protocol PURLsRepositoryProtocol: Sendable {
var purlsContainer: ModelContainer { get }
// ...
}
// Views query directly
@Query(sort: \StorablePURL.name) var purls: [StorablePURL]
```
**Caching Strategy:**
- **Write-through:** Network fetches automatically persist to Swift Data via streams
- **Read from cache:** Views use SwiftData @Query to read local data
- **Explicit refresh:** User-triggered or app lifecycle events fetch from network
- **No complex merge:** Network data is source of truth, overwrites local
- **Optimistic updates:** Some operations update UI immediately, then sync
**Benefits of Stream-Based Approach:**
1. **Automatic synchronization:** Data flows continuously from network to storage
2. **Decoupled coordination:** Repository doesn't manually call persistence after each network call
3. **Real-time updates:** UI stays synchronized via SwiftData observation
4. **Task management:** Stream tasks are managed in repository lifecycle
5. **Cancellation support:** Streams can be cancelled on repository deinit
**Conflict Resolution:**
Current approach: **Server wins** (last-write-wins)
- Network data always overwrites local cache
- No complex merge logic
- Suitable for single-user, single-device scenarios
- Conflicts rare due to API design
**Cache Invalidation:**
- **Explicit fetch:** Repository `fetchPURLs()` method triggers network call
- **Lifecycle events:** App foreground, user login trigger refreshes
- **Mutation operations:** Create/delete operations automatically refresh via stream
- **No TTL:** Data remains valid until explicitly refreshed
**Consequences:**
### Positive
- **Reactive UI:** SwiftData observation keeps views updated automatically
- **Offline viewing:** Local cache available without network
- **Clean coordination:** Streaming pattern reduces manual orchestration
- **Type safety:** @Model types provide compile-time safety
- **Performance:** Views read from local SwiftData, not waiting for network
### Negative
- **Memory:** Stream tasks run continuously during repository lifetime
- **Complexity:** Async stream coordination requires understanding async patterns
- **Limited offline:** Can't create/modify data offline (server is source of truth)
### Neutral
- **Conflict resolution:** Simple strategy works for current use case
- **Cache size:** No automatic cleanup, relies on periodic full refreshes
**When to Fetch:**
1. **User-initiated:** Pull-to-refresh, explicit button taps
2. **App lifecycle:** Returning to foreground
3. **After mutations:** Create/update/delete operations
4. **Initial load:** First time viewing a feature
**Related Decisions:**
- [ADR-006: Swift Data over Core Data](ADR-006-swift-data-over-core-data.md) - SwiftData as cache layer
- [ADR-011: Actor Isolation for Repository and Service Concurrency](ADR-011-actor-isolation-for-repository-concurrency.md) - Stream task management in actors
- [ADR-012: DTO-Based Data Flow](ADR-012-dto-based-data-flow.md) - DTOs flow through streams to persistence
**Notes:**
The streaming-based caching pattern provides automatic synchronization with minimal manual coordination. While it requires understanding async streams, it results in clean, reactive code where data flows naturally from network through repositories to local storage and UI.

View file

@ -0,0 +1,135 @@
# ADR-014: SwiftUI-First UI Development
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
When building the macOS application UI, I needed to decide on the UI framework approach. The options were:
1. **AppKit:** Traditional macOS framework with mature APIs
2. **Mixed approach:** SwiftUI with AppKit for specific features
3. **Pure SwiftUI:** SwiftUI exclusively, no AppKit/UIKit
Key considerations:
- **Modern development:** SwiftUI represents Apple's future direction
- **Cross-platform potential:** SwiftUI code could be reused for iOS/iPadOS
- **Declarative UI:** Simpler state management with SwiftUI
- **Consistency:** Single framework approach throughout
- **Learning investment:** SwiftUI is the better long-term investment
**Decision:**
I adopted a pure SwiftUI approach where:
1. **All UI is SwiftUI** - Views, scenes, navigation, everything
2. **No AppKit or UIKit** - Zero usage of legacy frameworks
3. **DesignSystem package** - Shared SwiftUI components and styling
4. **View composition over inheritance** - SwiftUI patterns throughout
5. **SwiftUI-native solutions** - Work within SwiftUI's capabilities
**SwiftUI Usage:**
- **Views:** All feature views built with SwiftUI
- **Navigation:** NavigationSplitView, NavigationStack
- **Scenes:** WindowGroup for auxiliary windows
- **Modifiers:** Custom view modifiers in DesignSystem
- **State management:** @Observable view models (not Combine)
- **Layout:** SwiftUI layout system (VStack, HStack, Grid, etc.)
**DesignSystem Package:**
Central package for reusable UI components:
```swift
// Shared components
- CardViewModifier
- RoundImageModifier
- TextFieldCardModifier
- TextEditorCardModifier
- CircularToolbarModifier
// Toolbar items
- RefreshToolbarItem
- ProgressToolbarItem
- AddressPickerToolbarItem
- SelectionToolbarItem
// Colors and styling
- Consistent color palette
- Shared spacing constants
- Common button styles
```
**View Architecture:**
```swift
// Feature view structure
struct StatusApp: View {
@State private var viewModel: StatusAppViewModel
var body: some View {
VStack {
makeHeaderView()
makeContentView()
makeFooterView()
}
}
@ViewBuilder
private func makeHeaderView() -> some View {
// Composed subviews
}
}
```
**Composition Patterns:**
- **@ViewBuilder methods:** Break down complex views
- **Extracted views:** Separate files for reusable components
- **Modifiers:** Chain modifiers from DesignSystem
- **No massive view bodies:** Keep views readable and composable
**Consequences:**
### Positive
- **Modern codebase:** Using Apple's recommended UI framework exclusively
- **Declarative syntax:** Easier to understand and maintain than imperative frameworks
- **Live previews:** Fast iteration with SwiftUI Previews
- **State management:** Natural integration with @Observable
- **Code reuse:** DesignSystem components shared across features
- **Future-proof:** Aligned with Apple's platform direction
- **Cross-platform potential:** Could port to iOS/iPadOS with minimal changes
- **No framework mixing:** Single mental model throughout codebase
- **Consistency:** All UI follows SwiftUI patterns
### Negative
- **SwiftUI limitations:** Must work within framework capabilities
- **Evolving APIs:** SwiftUI changes across OS versions
- **Learning curve:** Some features require creative SwiftUI solutions
### Neutral
- **API changes:** Need to stay current with SwiftUI evolution
- **Testing:** SwiftUI testing approaches still maturing
- **Performance:** Understanding SwiftUI's performance characteristics
**Design System Benefits:**
- **Consistency:** Shared components ensure uniform UI
- **Maintainability:** Update once, apply everywhere
- **Productivity:** Reusable components speed development
- **Isolation:** Design system can be developed/tested independently
**Related Decisions:**
- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Each feature contains SwiftUI views
- [ADR-005: Adoption of Swift Observation Framework](ADR-005-adoption-of-swift-observation-framework.md) - @Observable works naturally with SwiftUI
- [ADR-016: SwiftUI Previews with Mother Objects](ADR-016-swiftui-previews-with-mother-objects.md) - Previews enable rapid SwiftUI development
**Notes:**
Pure SwiftUI development provides a consistent, modern approach to UI development. By committing fully to SwiftUI without mixing in AppKit or UIKit, the codebase maintains simplicity and aligns with Apple's platform direction. The DesignSystem package ensures consistency across features, and all UI functionality is achieved using SwiftUI-native solutions.

View file

@ -0,0 +1,167 @@
# ADR-015: Context Menu Sharing Patterns
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
Content items throughout the application (PURLs, pictures, weblog entries, pastes) need consistent sharing capabilities. Users should be able to:
1. **Copy URLs** in various formats (plain URL, Markdown)
2. **Share externally** using system share sheet
3. **Share internally** to Statuslog
4. **Manage items** with edit/delete actions
I needed to establish a consistent pattern for context menus that would:
- Provide intuitive sharing options
- Maintain consistency across features
- Use native macOS patterns
- Be easy to implement in new features
**Decision:**
I adopted a standardized context menu structure using SwiftUI's native `ShareLink` for external sharing, combined with custom copy and internal sharing actions.
**Context Menu Structure:**
All content item views follow this consistent ordering:
1. **Edit/Management actions** (e.g., Edit, Modify)
2. **Copy options** (URL, Markdown formats)
3. **Native ShareLink** - System share sheet
4. **Internal sharing** (Share on Statuslog)
5. **Delete actions** (destructive, at bottom)
**Views with Context Menus:**
- `PURLView` - PURL management with `viewModel.permanentURL`
- `PictureView` - Picture sharing with `viewModel.somePicsURL`
- `WeblogEntryView` - Blog post sharing with `viewModel.permanentURL`
- `PasteView` - Paste sharing with `viewModel.permanentURL` (public pastes only)
**ShareLink Implementation:**
```swift
@ViewBuilder
private func makeShareMenuItem() -> some View {
ShareLink(item: viewModel.permanentURL) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
```
**Complete Context Menu Example:**
```swift
struct PURLView: View {
@State private var viewModel: PURLViewModel
var body: some View {
// ... view content ...
.contextMenu {
// 1. Edit/Management actions
Button("Edit") {
viewModel.edit()
}
Divider()
// 2. Copy options
Button("Copy URL") {
viewModel.copyURL()
}
Button("Copy as Markdown") {
viewModel.copyAsMarkdown()
}
Divider()
// 3. Native ShareLink
ShareLink(item: viewModel.permanentURL) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider()
// 4. Internal sharing
Button("Share on Statuslog") {
viewModel.shareOnStatuslog()
}
Divider()
// 5. Delete actions (destructive)
Button("Delete", role: .destructive) {
viewModel.delete()
}
}
}
}
```
**Copy Format Conventions:**
- **Copy URL:** Plain URL string for pasting anywhere
- **Copy as Markdown:** `[Title](URL)` format for documentation
- **Copy as Rich Text:** Formatted text with embedded link (when applicable)
**ShareLink Benefits:**
1. **Native integration:** Uses macOS share sheet
2. **System services:** Access to Mail, Messages, AirDrop, etc.
3. **User familiarity:** Standard macOS sharing experience
4. **No custom code:** SwiftUI provides implementation
5. **Consistent icon:** `square.and.arrow.up` system symbol
**Internal Sharing Pattern:**
"Share on Statuslog" creates a status update with the item's URL:
- Provides cross-feature integration
- Encourages content discovery
- Maintains context within the application
**Consequences:**
### Positive
- **Consistency:** All content items share similar interaction patterns
- **Discoverability:** Context menus make sharing obvious
- **Native feel:** ShareLink provides standard macOS experience
- **Flexibility:** Multiple copy formats serve different use cases
- **Maintenance:** Pattern is easy to replicate in new features
### Negative
- **Repetition:** Similar code across multiple views (mitigated by view model methods)
- **Menu length:** Full menu can be lengthy with all options
### Neutral
- **Menu order:** Established order must be maintained for consistency
- **Feature parity:** New content types should follow same pattern
**Implementation Guidelines:**
1. **Always include ShareLink** - Provide native system sharing
2. **Maintain consistent ordering** - Follow the five-section structure
3. **Use dividers** - Separate logical groups of actions
4. **Destructive actions last** - Delete always at bottom with `.destructive` role
5. **View model methods** - Keep logic in view model, not view
**Accessibility:**
- Label text clearly describes action
- System icons provide visual recognition
- Keyboard shortcuts for common actions
- VoiceOver-friendly labels
**Related Decisions:**
- [ADR-014: SwiftUI-First UI Development](ADR-014-swiftui-first-ui-development.md) - ShareLink is SwiftUI-native
- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Pattern implemented across features
**Notes:**
The standardized context menu pattern with ShareLink provides a consistent, native sharing experience across all content types. By establishing clear conventions for menu structure and actions, new features can easily maintain consistency with existing patterns. The use of SwiftUI's ShareLink ensures the app feels native to macOS while providing powerful sharing capabilities.

View file

@ -0,0 +1,210 @@
# ADR-016: SwiftUI Previews with Mother Objects
**Status:** Accepted
**Date:** 2025-01-11
**Context:**
SwiftUI Previews enable rapid UI development by providing live visual feedback without running the full application. However, previews require:
1. **Realistic data:** Views need properly configured view models and data
2. **Dependencies:** View models depend on services, repositories, etc.
3. **Multiple states:** Need to preview different UI states (empty, loaded, error)
4. **Maintainability:** Fixture creation should be reusable and consistent
5. **Simplicity:** Previews should be easy to write
I needed a pattern for creating test fixtures that would:
- Provide realistic preview data
- Be reusable across previews and tests
- Keep preview code clean and readable
- Make it easy to create different scenarios
**Decision:**
I adopted the Mother Object pattern (https://martinfowler.com/bliki/ObjectMother.html) for creating test fixtures used in SwiftUI Previews. Mother Objects are factory classes that create fully-configured test objects with realistic data.
**Implementation Pattern:**
### Mother Object Structure
```swift
#if DEBUG
enum SidebarViewModelMother {
@MainActor
static func makeSidebarViewModel(
loggedIn: Bool = false
) -> SidebarViewModel {
.init(
authSessionService: AuthSessionServiceMother.makeAuthSessionService(
loggedIn: loggedIn
)
)
}
}
#endif
```
**Key Characteristics:**
1. **DEBUG-only:** Wrapped in `#if DEBUG` to exclude from release builds
2. **Enum (no cases):** Namespace for factory methods, cannot be instantiated
3. **Static methods:** All factory methods are static
4. **Sensible defaults:** Parameters have default values for common cases
5. **Composable:** Mother Objects can call other Mother Objects
6. **@MainActor when needed:** For types requiring main thread
**Usage in Previews:**
```swift
#Preview("Logged in") {
SidebarView(
viewModel: SidebarViewModelMother.makeSidebarViewModel(
loggedIn: true
),
selection: .constant(.statuslog)
)
.frame(width: 180)
}
#Preview("Logged out") {
SidebarView(
viewModel: SidebarViewModelMother.makeSidebarViewModel(
loggedIn: false
),
selection: .constant(.weblog)
)
.frame(width: 180)
}
```
**Mother Object Patterns:**
### Service Mocks
```swift
enum AuthSessionServiceMother {
static func makeAuthSessionService(
loggedIn: Bool = true
) -> any AuthSessionServiceProtocol {
MockAuthSessionService(isLoggedIn: loggedIn)
}
}
```
### View Models
```swift
enum StatusViewModelMother {
@MainActor
static func makeStatusViewModel(
statuses: [Status] = StatusMother.makeStatuses(),
isLoading: Bool = false
) -> StatusViewModel {
.init(
statuses: statuses,
isLoading: isLoading,
repository: StatusRepositoryMother.makeRepository()
)
}
}
```
### Domain Models
```swift
enum StatusMother {
static func makeStatus(
content: String = "Test status",
createdAt: Date = Date()
) -> Status {
Status(
id: UUID().uuidString,
content: content,
createdAt: createdAt
)
}
static func makeStatuses(count: Int = 5) -> [Status] {
(0..<count).map { index in
makeStatus(content: "Test status \(index + 1)")
}
}
}
```
**Coverage:**
Mother Objects are used in **90% of views** for previews, providing:
- Multiple preview states per view
- Consistent test data across the application
- Easy scenario creation (empty, loading, error states)
- Reduced boilerplate in preview code
**Benefits of Mother Object Pattern:**
1. **Reusability:** Same fixtures used in previews and tests
2. **Consistency:** Uniform test data across features
3. **Readability:** Preview code is clean and intention-revealing
4. **Maintainability:** Changes to object creation centralized
5. **Discoverability:** Easy to find existing fixtures
6. **Composition:** Mother Objects build on each other
**File Organization:**
```
PackageName/
├── Sources/
│ ├── PackageName/
│ │ └── Fixtures/
│ │ ├── ViewModelMother.swift
│ │ ├── ServiceMother.swift
│ │ └── ModelMother.swift
```
**Naming Convention:**
- **File:** `{Type}Mother.swift` (e.g., `StatusViewModelMother.swift`)
- **Enum:** `{Type}Mother` (e.g., `StatusViewModelMother`)
- **Methods:** `make{Type}()` with parameters for variation
**Consequences:**
### Positive
- **Rapid development:** Fast UI iteration with live previews
- **Visual testing:** Catch UI issues immediately
- **Multiple states:** Easy to preview edge cases
- **Reduced boilerplate:** Clean, readable preview code
- **Reusable fixtures:** Same code for previews and tests
- **Documentation:** Previews serve as living documentation
- **No release bloat:** DEBUG-only code excluded from builds
### Negative
- **Maintenance:** Mother Objects need updates when APIs change
- **Discovery:** Need to know Mother Objects exist
- **Duplication:** Similar patterns across features
### Neutral
- **Pattern consistency:** Team must understand and follow pattern
- **Fixture realism:** Balance between simple and realistic data
**Integration with Testing:**
Mother Objects serve double duty:
- **Previews:** Visual development and verification
- **Unit tests:** Provide test fixtures for logic testing
- **Snapshot tests:** If implemented, use same fixtures
**Related Decisions:**
- [ADR-014: SwiftUI-First UI Development](ADR-014-swiftui-first-ui-development.md) - Previews are key to SwiftUI development
- [ADR-005: Adoption of Swift Observation Framework](ADR-005-adoption-of-swift-observation-framework.md) - @Observable view models work naturally in previews
**Notes:**
The Mother Object pattern provides a clean, reusable approach to creating test fixtures for SwiftUI Previews. By using Mother Objects in 90% of views, the codebase maintains consistent fixture creation while enabling rapid UI development. The pattern's reusability means the same fixtures support both visual development (previews) and automated testing, reducing duplication and maintenance burden.

View file

@ -0,0 +1,93 @@
# Architecture Decision Records (ADRs)
This directory contains Architecture Decision Records for the OMG application. These documents capture important architectural decisions, their context, and consequences.
## What is an ADR?
An Architecture Decision Record (ADR) is a document that captures an important architectural decision made along with its context and consequences. This helps understand why certain decisions were made and provides guidance for future development.
## ADR Format
Each ADR follows this structure:
- **Status:** Accepted, Proposed, Deprecated, or Superseded
- **Date:** When the decision was made
- **Context:** The situation and requirements that led to the decision
- **Decision:** What was decided and key principles
- **Consequences:** Positive, negative, and neutral outcomes
- **Related Decisions:** Links to related ADRs
- **Notes:** Additional context or implementation details
## Core Architecture ADRs
### [ADR-001: Modular Architecture with Swift Package Manager](ADR-001-modular-architecture-with-spm.md)
Decision to use Swift Package Manager for modularizing the codebase into discrete, maintainable packages with clear dependency boundaries.
### [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md)
Establishes the strict layered architecture with one-way dependency flow from Views → ViewModels → Repositories → Services → Foundation Modules.
### [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md)
Defines how features are organized as complete vertical slices with their own layers, and how they integrate with the main application through AppFactory patterns.
## Technology Decisions
### [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md)
Migration from Combine to async/await in services and repositories for cleaner asynchronous code and better integration with modern Swift concurrency.
### [ADR-005: Adoption of Swift Observation Framework](ADR-005-adoption-of-swift-observation-framework.md)
Adoption of Swift's @Observable macro to replace ObservableObject and @Published in view models, completing the removal of Combine from the UI layer.
### [ADR-006: Swift Data over Core Data](ADR-006-swift-data-over-core-data.md)
Migration from Core Data to Swift Data to eliminate excessive boilerplate for the application's straightforward data models.
### [ADR-007: MicroClient for HTTP Communication](ADR-007-microclient-for-http-communication.md)
Use of MicroClient, a custom-built lightweight HTTP client, for all API communication with type-safe request/response patterns.
### [ADR-008: MicroContainer for Dependency Injection](ADR-008-microcontainer-for-dependency-injection.md)
Use of MicroContainer (DependencyContainer), a custom-built lightweight DI container, for managing dependencies and factory registration throughout the application.
## Design Patterns
### [ADR-009: Protocol-First Repository and Service Boundaries](ADR-009-protocol-first-repository-and-service-boundaries.md)
Use of public protocols to define repository and service contracts, with Sendable conformance for concurrency safety. Only protocols are documented; implementations remain internal.
### [ADR-010: AppFactory Pattern for Feature Integration](ADR-010-appfactory-pattern-for-feature-integration.md)
Standardized factory pattern for feature integration with makeAppView(), makeScene(), and makeSettingsView() methods providing consistent entry points for all features.
### [ADR-011: Actor Isolation for Repository and Service Concurrency](ADR-011-actor-isolation-for-repository-concurrency.md)
Use of Swift actors for services and repositories to ensure thread safety and prevent data races while maintaining clean async/await APIs.
## Data Flow
### [ADR-012: DTO-Based Data Flow](ADR-012-dto-based-data-flow.md)
Use of Data Transfer Objects (DTOs) in the OMGAPI package to represent API contracts, with repositories responsible for mapping DTOs to domain models or persistence models.
### [ADR-013: Repository Caching and Streaming Patterns](ADR-013-repository-caching-and-streaming-patterns.md)
Stream-based caching pattern where repositories use AsyncStream to coordinate automatic data synchronization between network and SwiftData persistence layers.
## UI/UX Patterns
### [ADR-014: SwiftUI-First UI Development](ADR-014-swiftui-first-ui-development.md)
Pure SwiftUI approach for all UI development with no AppKit or UIKit usage, leveraging the DesignSystem package for shared components and consistent styling.
### [ADR-015: Context Menu Sharing Patterns](ADR-015-context-menu-sharing-patterns.md)
Standardized context menu structure for content items using native ShareLink for external sharing, with consistent ordering of edit, copy, share, and delete actions.
### [ADR-016: SwiftUI Previews with Mother Objects](ADR-016-swiftui-previews-with-mother-objects.md)
Use of Mother Object pattern for creating reusable test fixtures that support SwiftUI Previews across 90% of views, enabling rapid UI development with realistic data.
## Contributing
When making significant architectural decisions:
1. Create a new ADR using the next available number
2. Follow the established format
3. Link to related ADRs
4. Update this README with the new ADR
## Status Definitions
- **Proposed:** Decision under consideration
- **Accepted:** Decision is approved and implemented
- **Deprecated:** No longer recommended but not yet removed
- **Superseded:** Replaced by another ADR (link to replacement)

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Otávio C.
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.

View file

@ -0,0 +1,614 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
E211DA8429CA5AC000A11C82 /* Now in Frameworks */ = {isa = PBXBuildFile; productRef = E211DA8329CA5AC000A11C82 /* Now */; };
E213884229A5153D00CE1692 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = E213884129A5153D00CE1692 /* Account */; };
E215670929BA86FF0062C8C3 /* AuthSessionService in Frameworks */ = {isa = PBXBuildFile; productRef = E215670829BA86FF0062C8C3 /* AuthSessionService */; };
E215670B29BA86FF0062C8C3 /* AuthSessionServiceInterface in Frameworks */ = {isa = PBXBuildFile; productRef = E215670A29BA86FF0062C8C3 /* AuthSessionServiceInterface */; };
E22BDE5B29B69ECC00B98BD9 /* Sidebar in Frameworks */ = {isa = PBXBuildFile; productRef = E22BDE5A29B69ECC00B98BD9 /* Sidebar */; };
E257927C29D9E68700D985C3 /* Webpage in Frameworks */ = {isa = PBXBuildFile; productRef = E257927B29D9E68700D985C3 /* Webpage */; };
E262C8F829C0641200B28EFB /* PURLs in Frameworks */ = {isa = PBXBuildFile; productRef = E262C8F729C0641200B28EFB /* PURLs */; };
E267BD5629BA9C7A0076C022 /* SessionService in Frameworks */ = {isa = PBXBuildFile; productRef = E267BD5529BA9C7A0076C022 /* SessionService */; };
E267BD5829BA9C7A0076C022 /* SessionServiceInterface in Frameworks */ = {isa = PBXBuildFile; productRef = E267BD5729BA9C7A0076C022 /* SessionServiceInterface */; };
E2A89E4629B72FC900C22586 /* Route in Frameworks */ = {isa = PBXBuildFile; productRef = E2A89E4529B72FC900C22586 /* Route */; };
E2BC2B472975840300DE5FB6 /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = E2BC2B462975840300DE5FB6 /* Auth */; };
E2D07E4529A80D8C001F58CB /* AccountUpdateService in Frameworks */ = {isa = PBXBuildFile; productRef = E2D07E4429A80D8C001F58CB /* AccountUpdateService */; };
EA7632CD2EAEBFDD00E9CD6E /* Shortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = EA7632CC2EAEBFDD00E9CD6E /* Shortcuts */; };
EA8DCD482964DC4500AAF957 /* Status in Frameworks */ = {isa = PBXBuildFile; productRef = EA8DCD472964DC4500AAF957 /* Status */; };
EAB0E9D22ED646BF000B7932 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = EAB0E9D12ED646BF000B7932 /* AsyncAlgorithms */; };
EAB0E9D62ED64720000B7932 /* MicroClient in Frameworks */ = {isa = PBXBuildFile; productRef = EAB0E9D52ED64720000B7932 /* MicroClient */; };
EAD89BCD2E6B2C0700C9041F /* Weblog in Frameworks */ = {isa = PBXBuildFile; productRef = EAD89BCC2E6B2C0700C9041F /* Weblog */; };
EADEE5FF2E78744900204760 /* Pics in Frameworks */ = {isa = PBXBuildFile; productRef = EADEE5FE2E78744900204760 /* Pics */; };
EAE1723E29F5C0CC00AEC787 /* Pastebin in Frameworks */ = {isa = PBXBuildFile; productRef = EAE1723D29F5C0CC00AEC787 /* Pastebin */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
E205A369296C0376007D092D /* Auth */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Auth; sourceTree = "<group>"; };
E210DF1229CA57AB0049B9E7 /* Now */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Now; sourceTree = "<group>"; };
E215670C29BA9B2F0062C8C3 /* SessionService */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SessionService; sourceTree = "<group>"; };
E2208AFE29AED6A6009E69BC /* PURLs */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PURLs; sourceTree = "<group>"; };
E23FA85629A6E5F0003CFDF8 /* AccountUpdate */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AccountUpdate; sourceTree = "<group>"; };
E257927A29D9E62700D985C3 /* Webpage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Webpage; sourceTree = "<group>"; };
E28061D029B5421100826BE1 /* Route */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Route; sourceTree = "<group>"; };
E28061D129B543D800826BE1 /* Sidebar */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Sidebar; sourceTree = "<group>"; };
E28A20912A72850100483749 /* OMGAPI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OMGAPI; sourceTree = "<group>"; };
E2A85B0C29BA85E000AD6AB8 /* AuthSession */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AuthSession; sourceTree = "<group>"; };
E2BC2B4B29758BBE00DE5FB6 /* DesignSystem */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = DesignSystem; sourceTree = "<group>"; };
E2C1505329A2933800FCB6F1 /* Utilities */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Utilities; sourceTree = "<group>"; };
E2E57A9829AE97AD00531E82 /* FoundationExtensions */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = FoundationExtensions; sourceTree = "<group>"; };
E2FA03B929A0EA3C00DDB75E /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Account; sourceTree = "<group>"; };
EA7632CB2EAEBFD000E9CD6E /* Shortcuts */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Shortcuts; sourceTree = "<group>"; };
EA8DCD462964DC0D00AAF957 /* Status */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Status; sourceTree = "<group>"; };
EAC944D4295E56E9004EE34F /* OMG.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OMG.app; sourceTree = BUILT_PRODUCTS_DIR; };
EAD89BCB2E6B2BF900C9041F /* Weblog */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Weblog; sourceTree = "<group>"; };
EADEE5FD2E786D3900204760 /* Pics */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pics; sourceTree = "<group>"; };
EAE1723C29F5C08000AEC787 /* Pastebin */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pastebin; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EAA086952E8EA353005D4406 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
"Supporting Files/Info.plist",
);
target = EAC944D3295E56E9004EE34F /* OMG */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EAA086842E8EA353005D4406 /* OMG */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EAA086952E8EA353005D4406 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = OMG; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
EAC944D1295E56E9004EE34F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E267BD5829BA9C7A0076C022 /* SessionServiceInterface in Frameworks */,
E215670B29BA86FF0062C8C3 /* AuthSessionServiceInterface in Frameworks */,
E2A89E4629B72FC900C22586 /* Route in Frameworks */,
EADEE5FF2E78744900204760 /* Pics in Frameworks */,
E262C8F829C0641200B28EFB /* PURLs in Frameworks */,
E215670929BA86FF0062C8C3 /* AuthSessionService in Frameworks */,
EAD89BCD2E6B2C0700C9041F /* Weblog in Frameworks */,
E2BC2B472975840300DE5FB6 /* Auth in Frameworks */,
E213884229A5153D00CE1692 /* Account in Frameworks */,
E22BDE5B29B69ECC00B98BD9 /* Sidebar in Frameworks */,
EAE1723E29F5C0CC00AEC787 /* Pastebin in Frameworks */,
EAB0E9D22ED646BF000B7932 /* AsyncAlgorithms in Frameworks */,
E267BD5629BA9C7A0076C022 /* SessionService in Frameworks */,
E211DA8429CA5AC000A11C82 /* Now in Frameworks */,
EAB0E9D62ED64720000B7932 /* MicroClient in Frameworks */,
EA8DCD482964DC4500AAF957 /* Status in Frameworks */,
E257927C29D9E68700D985C3 /* Webpage in Frameworks */,
EA7632CD2EAEBFDD00E9CD6E /* Shortcuts in Frameworks */,
E2D07E4529A80D8C001F58CB /* AccountUpdateService in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
EAC944CB295E56E9004EE34F = {
isa = PBXGroup;
children = (
EAA086842E8EA353005D4406 /* OMG */,
EAC944E6295E5873004EE34F /* Packages */,
EAC944D5295E56E9004EE34F /* Products */,
EAD763042960953600E18E02 /* Frameworks */,
);
sourceTree = "<group>";
};
EAC944D5295E56E9004EE34F /* Products */ = {
isa = PBXGroup;
children = (
EAC944D4295E56E9004EE34F /* OMG.app */,
);
name = Products;
sourceTree = "<group>";
};
EAC944E6295E5873004EE34F /* Packages */ = {
isa = PBXGroup;
children = (
E2FA03B929A0EA3C00DDB75E /* Account */,
E23FA85629A6E5F0003CFDF8 /* AccountUpdate */,
E205A369296C0376007D092D /* Auth */,
E2A85B0C29BA85E000AD6AB8 /* AuthSession */,
E2BC2B4B29758BBE00DE5FB6 /* DesignSystem */,
E2E57A9829AE97AD00531E82 /* FoundationExtensions */,
E210DF1229CA57AB0049B9E7 /* Now */,
E28A20912A72850100483749 /* OMGAPI */,
EAE1723C29F5C08000AEC787 /* Pastebin */,
EADEE5FD2E786D3900204760 /* Pics */,
E2208AFE29AED6A6009E69BC /* PURLs */,
E28061D029B5421100826BE1 /* Route */,
E215670C29BA9B2F0062C8C3 /* SessionService */,
EA7632CB2EAEBFD000E9CD6E /* Shortcuts */,
E28061D129B543D800826BE1 /* Sidebar */,
EA8DCD462964DC0D00AAF957 /* Status */,
E2C1505329A2933800FCB6F1 /* Utilities */,
EAD89BCB2E6B2BF900C9041F /* Weblog */,
E257927A29D9E62700D985C3 /* Webpage */,
);
path = Packages;
sourceTree = "<group>";
};
EAD763042960953600E18E02 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
EAC944D3295E56E9004EE34F /* OMG */ = {
isa = PBXNativeTarget;
buildConfigurationList = EAC944E3295E56EA004EE34F /* Build configuration list for PBXNativeTarget "OMG" */;
buildPhases = (
EAC944D0295E56E9004EE34F /* Sources */,
EAC944D1295E56E9004EE34F /* Frameworks */,
EAC944D2295E56E9004EE34F /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
EAA086842E8EA353005D4406 /* OMG */,
);
name = OMG;
packageProductDependencies = (
EA8DCD472964DC4500AAF957 /* Status */,
E2BC2B462975840300DE5FB6 /* Auth */,
E213884129A5153D00CE1692 /* Account */,
E2D07E4429A80D8C001F58CB /* AccountUpdateService */,
E22BDE5A29B69ECC00B98BD9 /* Sidebar */,
E2A89E4529B72FC900C22586 /* Route */,
E215670829BA86FF0062C8C3 /* AuthSessionService */,
E215670A29BA86FF0062C8C3 /* AuthSessionServiceInterface */,
E267BD5529BA9C7A0076C022 /* SessionService */,
E267BD5729BA9C7A0076C022 /* SessionServiceInterface */,
E262C8F729C0641200B28EFB /* PURLs */,
E211DA8329CA5AC000A11C82 /* Now */,
E257927B29D9E68700D985C3 /* Webpage */,
EAE1723D29F5C0CC00AEC787 /* Pastebin */,
EAD89BCC2E6B2C0700C9041F /* Weblog */,
EADEE5FE2E78744900204760 /* Pics */,
EA7632CC2EAEBFDD00E9CD6E /* Shortcuts */,
EAB0E9D12ED646BF000B7932 /* AsyncAlgorithms */,
EAB0E9D52ED64720000B7932 /* MicroClient */,
);
productName = OMG;
productReference = EAC944D4295E56E9004EE34F /* OMG.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
EAC944CC295E56E9004EE34F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1420;
LastUpgradeCheck = 2600;
TargetAttributes = {
EAC944D3295E56E9004EE34F = {
CreatedOnToolsVersion = 14.2;
};
};
};
buildConfigurationList = EAC944CF295E56E9004EE34F /* Build configuration list for PBXProject "OMG" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = EAC944CB295E56E9004EE34F;
packageReferences = (
E25F79C62966E7D10015CCA4 /* XCRemoteSwiftPackageReference "MicroContainer" */,
EAB0E9D02ED646BF000B7932 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */,
EAB0E9D32ED646D4000B7932 /* XCRemoteSwiftPackageReference "swift-collections" */,
EAB0E9D42ED64720000B7932 /* XCRemoteSwiftPackageReference "MicroClient" */,
);
productRefGroup = EAC944D5295E56E9004EE34F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
EAC944D3295E56E9004EE34F /* OMG */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
EAC944D2295E56E9004EE34F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
EAC944D0295E56E9004EE34F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
EAC944E1295E56EA004EE34F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = S9X9XY5GF8;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "-enable-upcoming-feature ExistentialAny";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0;
};
name = Debug;
};
EAC944E2295E56EA004EE34F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = S9X9XY5GF8;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "-enable-upcoming-feature ExistentialAny";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 6.0;
};
name = Release;
};
EAC944E4295E56EA004EE34F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = TritonIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = "OMG/Supporting Files/OMG.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"OMG/Preview Content\"";
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "OMG/Supporting Files/Info.plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.OMG;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
EAC944E5295E56EA004EE34F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = TritonIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = "OMG/Supporting Files/OMG.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"OMG/Preview Content\"";
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "OMG/Supporting Files/Info.plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.OMG;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
EAC944CF295E56E9004EE34F /* Build configuration list for PBXProject "OMG" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EAC944E1295E56EA004EE34F /* Debug */,
EAC944E2295E56EA004EE34F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EAC944E3295E56EA004EE34F /* Build configuration list for PBXNativeTarget "OMG" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EAC944E4295E56EA004EE34F /* Debug */,
EAC944E5295E56EA004EE34F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
E25F79C62966E7D10015CCA4 /* XCRemoteSwiftPackageReference "MicroContainer" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/otaviocc/MicroContainer.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.0.6;
};
};
EAB0E9D02ED646BF000B7932 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-async-algorithms.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.0;
};
};
EAB0E9D32ED646D4000B7932 /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.3.0;
};
};
EAB0E9D42ED64720000B7932 /* XCRemoteSwiftPackageReference "MicroClient" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/otaviocc/MicroClient";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.0.27;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
E211DA8329CA5AC000A11C82 /* Now */ = {
isa = XCSwiftPackageProductDependency;
productName = Now;
};
E213884129A5153D00CE1692 /* Account */ = {
isa = XCSwiftPackageProductDependency;
productName = Account;
};
E215670829BA86FF0062C8C3 /* AuthSessionService */ = {
isa = XCSwiftPackageProductDependency;
productName = AuthSessionService;
};
E215670A29BA86FF0062C8C3 /* AuthSessionServiceInterface */ = {
isa = XCSwiftPackageProductDependency;
productName = AuthSessionServiceInterface;
};
E22BDE5A29B69ECC00B98BD9 /* Sidebar */ = {
isa = XCSwiftPackageProductDependency;
productName = Sidebar;
};
E257927B29D9E68700D985C3 /* Webpage */ = {
isa = XCSwiftPackageProductDependency;
productName = Webpage;
};
E262C8F729C0641200B28EFB /* PURLs */ = {
isa = XCSwiftPackageProductDependency;
productName = PURLs;
};
E267BD5529BA9C7A0076C022 /* SessionService */ = {
isa = XCSwiftPackageProductDependency;
productName = SessionService;
};
E267BD5729BA9C7A0076C022 /* SessionServiceInterface */ = {
isa = XCSwiftPackageProductDependency;
productName = SessionServiceInterface;
};
E2A89E4529B72FC900C22586 /* Route */ = {
isa = XCSwiftPackageProductDependency;
productName = Route;
};
E2BC2B462975840300DE5FB6 /* Auth */ = {
isa = XCSwiftPackageProductDependency;
productName = Auth;
};
E2D07E4429A80D8C001F58CB /* AccountUpdateService */ = {
isa = XCSwiftPackageProductDependency;
productName = AccountUpdateService;
};
EA7632CC2EAEBFDD00E9CD6E /* Shortcuts */ = {
isa = XCSwiftPackageProductDependency;
productName = Shortcuts;
};
EA8DCD472964DC4500AAF957 /* Status */ = {
isa = XCSwiftPackageProductDependency;
productName = Status;
};
EAB0E9D12ED646BF000B7932 /* AsyncAlgorithms */ = {
isa = XCSwiftPackageProductDependency;
package = EAB0E9D02ED646BF000B7932 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */;
productName = AsyncAlgorithms;
};
EAB0E9D52ED64720000B7932 /* MicroClient */ = {
isa = XCSwiftPackageProductDependency;
package = EAB0E9D42ED64720000B7932 /* XCRemoteSwiftPackageReference "MicroClient" */;
productName = MicroClient;
};
EAD89BCC2E6B2C0700C9041F /* Weblog */ = {
isa = XCSwiftPackageProductDependency;
productName = Weblog;
};
EADEE5FE2E78744900204760 /* Pics */ = {
isa = XCSwiftPackageProductDependency;
productName = Pics;
};
EAE1723D29F5C0CC00AEC787 /* Pastebin */ = {
isa = XCSwiftPackageProductDependency;
productName = Pastebin;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = EAC944CC295E56E9004EE34F /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,42 @@
{
"originHash" : "cf8a43d25f0e66b6fe0462bffcf0426bf3b10f5b9ce5753e355e7513f9738e69",
"pins" : [
{
"identity" : "microclient",
"kind" : "remoteSourceControl",
"location" : "https://github.com/otaviocc/MicroClient.git",
"state" : {
"revision" : "794c206d1bf811f085f3db08901dead0554437f7",
"version" : "0.0.27"
}
},
{
"identity" : "microcontainer",
"kind" : "remoteSourceControl",
"location" : "https://github.com/otaviocc/MicroContainer.git",
"state" : {
"revision" : "255f98b9807a44fad475df01d14a2f47c501a4b3",
"version" : "0.0.6"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "2773d4125311133a2f705ec374c363a935069d45",
"version" : "1.1.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
}
],
"version" : 3
}

View file

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EAC944D3295E56E9004EE34F"
BuildableName = "OMG.app"
BlueprintName = "OMG"
ReferencedContainer = "container:OMG.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EAC944D3295E56E9004EE34F"
BuildableName = "OMG.app"
BlueprintName = "OMG"
ReferencedContainer = "container:OMG.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "OS_ACTIVITY_MODE"
value = "disable"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EAC944D3295E56E9004EE34F"
BuildableName = "OMG.app"
BlueprintName = "OMG"
ReferencedContainer = "container:OMG.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.678",
"green" : "0.410",
"red" : "0.999"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View file

@ -0,0 +1,42 @@
{
"fill-specializations" : [
{
"value" : "system-light"
},
{
"appearance" : "dark",
"value" : "system-dark"
}
],
"groups" : [
{
"layers" : [
{
"fill" : "automatic",
"glass" : true,
"hidden" : false,
"image-name" : "1024.png",
"name" : "1024",
"position" : {
"scale" : 0.8,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"squares" : "shared"
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>com.otaviocc.triton</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.otaviocc.triton-triton</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

67
OMG/TritonApp.swift Normal file
View file

@ -0,0 +1,67 @@
import SwiftUI
/// The main application entry point for OMG.
///
/// `TritonApp` configures and manages all scenes in the application, including:
/// - Main window with primary navigation
/// - Feature-specific composition scenes (Status, PURLs, Web Page, Now Page, Weblog, Pics, Pastebin)
/// - Application settings
///
/// All scenes share a single `TritonEnvironment` instance for dependency injection.
@main
struct TritonApp: App {
// MARK: - Properties
private let environment = TritonEnvironment()
// MARK: - Public
var body: some Scene {
// Main scene
TritonScene(
environment: environment
)
// Feature scenes
environment
.statusAppFactory
.makeScene()
environment
.purlsAppFactory
.makeScene()
environment
.webpageAppFactory
.makeScene()
environment
.nowAppFactory
.makeScene()
environment
.weblogAppFactory
.makeScene()
environment
.picsAppFactory
.makeScene()
environment
.pastebinAppFactory
.makeScene()
// Settings
#if os(macOS)
Settings {
SettingsView(
environment: environment
)
}
#endif
}
}

378
OMG/TritonEnvironment.swift Normal file
View file

@ -0,0 +1,378 @@
import Account
import AccountUpdateService
import Auth
import AuthSessionService
import AuthSessionServiceInterface
import ClipboardService
import MicroClient
import MicroContainer
import Now
import OMGAPI
import Pastebin
import Pics
import PURLs
import SessionService
import SessionServiceInterface
import Shortcuts
import Sidebar
import Status
import Weblog
import Webpage
/// The main dependency injection container protocol for the OMG application.
///
/// `TritonEnvironmentProtocol` defines the contract for resolving and providing access
/// to all core application dependencies. It serves as the central hub for dependency
/// injection, ensuring that shared services and feature-specific app factories are
/// available throughout the application lifecycle.
///
/// ## Architecture
///
/// The protocol follows a modular architecture pattern where:
/// - **Core Services**: Shared infrastructure services used across multiple features
/// - **App Factories**: Feature-specific factories that create complete app modules
/// - **Dependency Resolution**: All dependencies are resolved through a container-based system
///
/// ## Core Services
///
/// The protocol provides access to fundamental services that are shared across features:
/// - Authentication and session management
/// - Network communication
/// - User session state
///
/// ## Feature Factories
///
/// Each major application feature has its own dedicated factory that encapsulates
/// all the dependencies needed for that feature area:
/// - Status updates and timeline management
/// - User authentication flows
/// - Account management and profile settings
/// - PURLs (permanent URL) creation and management
/// - "Now page" content editing
/// - Web page creation and editing
/// - Pastebin functionality
/// - Weblog posting and management
/// - Application sidebar navigation
///
/// ## Usage
///
/// Conforming types should handle dependency registration and resolution internally,
/// typically using a dependency injection container:
///
/// ```swift
/// struct MyEnvironment: TritonEnvironmentProtocol {
/// private let container = DependencyContainer()
///
/// var authSessionService: any AuthSessionServiceProtocol {
/// container.resolve()
/// }
///
/// // ... other dependencies
/// }
/// ```
///
/// ## Thread Safety
///
/// All properties should be thread-safe and support concurrent access since they
/// may be accessed from multiple contexts throughout the application.
protocol TritonEnvironmentProtocol {
// MARK: - Core Services
/// Provides authentication session management and token handling.
///
/// Used for managing user authentication state, storing and retrieving
/// access tokens, and handling authentication lifecycle events.
var authSessionService: any AuthSessionServiceProtocol { get }
/// Provides user session state management across the application.
///
/// Manages the current user's session information, selected addresses,
/// and session-related state that needs to be shared between features.
var sessionService: any SessionServiceProtocol { get }
/// Provides network communication capabilities for API requests.
///
/// Configured with authentication token providers and handles all
/// HTTP communication with the OMG API services.
var networkClient: any NetworkClientProtocol { get }
/// Provides App Intent integration for Spotlight, Siri, and Shortcuts.
///
/// Observes notifications posted by App Intents and coordinates window
/// opening actions, bridging system-level shortcuts to the application UI.
var shortcutsService: any ShortcutsServiceProtocol { get }
/// Provides clipboard capabilities.
///
/// Used for adding URLs, Markdown content, and more to the system's
/// pasteboard.
var clipboardService: any ClipboardServiceProtocol { get }
// MARK: - Feature App Factories
/// Factory for creating Status feature components.
///
/// Provides access to status posting, timeline viewing, and status
/// management functionality.
var statusAppFactory: StatusAppFactory { get }
/// Factory for creating Authentication feature components.
///
/// Handles user login, logout, and authentication flow management.
var authAppFactory: AuthAppFactory { get }
/// Factory for creating Account management components.
///
/// Provides user account viewing, profile management, and account
/// settings functionality.
var accountAppFactory: AccountAppFactory { get }
/// Factory for creating Account update service components.
///
/// Handles account information updates, address changes, and profile
/// synchronization across the application.
var accountUpdateAppFactory: AccountUpdateAppFactory { get }
/// Factory for creating Sidebar navigation components.
///
/// Provides the main application navigation sidebar with feature
/// selection and routing capabilities.
var sidebarAppFactory: SidebarAppFactory { get }
/// Factory for creating PURLs (Permanent URLs) feature components.
///
/// Handles creation, management, and sharing of permanent redirect URLs.
var purlsAppFactory: PURLsAppFactory { get }
/// Factory for creating "Now page" feature components.
///
/// Provides editing and management of the user's "now" status page content.
var nowAppFactory: NowAppFactory { get }
/// Factory for creating Web page editing components.
///
/// Handles creation and editing of web pages hosted on the OMG platform.
var webpageAppFactory: WebpageAppFactory { get }
/// Factory for creating Pastebin feature components.
///
/// Provides text paste creation, editing, sharing, and management functionality.
var pastebinAppFactory: PastebinAppFactory { get }
/// Factory for creating Weblog feature components.
///
/// Handles blog post creation, editing, and weblog management functionality.
var weblogAppFactory: WeblogAppFactory { get }
/// Factory for creating Pics feature components.
///
/// Provides image hosting, sharing, and management functionality
/// on the some.pics subdomain platform.
var picsAppFactory: PicsAppFactory { get }
}
struct TritonEnvironment: TritonEnvironmentProtocol {
// MARK: - Properties
var authSessionService: any AuthSessionServiceProtocol { container.resolve() }
var sessionService: any SessionServiceProtocol { container.resolve() }
var networkClient: any NetworkClientProtocol { container.resolve() }
var shortcutsService: any ShortcutsServiceProtocol { container.resolve() }
var clipboardService: any ClipboardServiceProtocol { container.resolve() }
var statusAppFactory: StatusAppFactory { container.resolve() }
var authAppFactory: AuthAppFactory { container.resolve() }
var accountAppFactory: AccountAppFactory { container.resolve() }
var accountUpdateAppFactory: AccountUpdateAppFactory { container.resolve() }
var sidebarAppFactory: SidebarAppFactory { container.resolve() }
var purlsAppFactory: PURLsAppFactory { container.resolve() }
var nowAppFactory: NowAppFactory { container.resolve() }
var webpageAppFactory: WebpageAppFactory { container.resolve() }
var pastebinAppFactory: PastebinAppFactory { container.resolve() }
var weblogAppFactory: WeblogAppFactory { container.resolve() }
var picsAppFactory: PicsAppFactory { container.resolve() }
private let container = DependencyContainer()
// MARK: - Lifecycle
init() {
self.init(
authSessionServiceFactory: AuthSessionServiceFactory(),
sessionServiceFactory: SessionServiceFactory(),
networkClient: OMGAPIFactory(),
shortcutsServiceFactory: ShortcutsServiceFactory(),
clipboardServiceFactory: ClipboardServiceFactory()
)
}
init(
authSessionServiceFactory: any AuthSessionServiceFactoryProtocol,
sessionServiceFactory: any SessionServiceFactoryProtocol,
networkClient: any OMGAPIFactoryProtocol,
shortcutsServiceFactory: any ShortcutsServiceFactoryProtocol,
clipboardServiceFactory: any ClipboardServiceFactoryProtocol
) {
container.register(
type: (any AuthSessionServiceProtocol).self,
allocation: .static
) { _ in
authSessionServiceFactory.makeAuthSessionService()
}
container.register(
type: (any SessionServiceProtocol).self,
allocation: .static
) { _ in
sessionServiceFactory.makeSessionService()
}
container.register(
type: (any NetworkClientProtocol).self,
allocation: .static
) { container in
let authSessionService = container.resolve() as any AuthSessionServiceProtocol
return networkClient.makeOMGAPIClient(
authTokenProvider: {
await authSessionService.accessToken
}
)
}
container.register(
type: (any ShortcutsServiceProtocol).self,
allocation: .static
) { _ in
shortcutsServiceFactory.makeShortcutsService()
}
container.register(
type: (any ClipboardServiceProtocol).self,
allocation: .static
) { _ in
clipboardServiceFactory.makeClipboardService()
}
container.register(
type: StatusAppFactory.self,
allocation: .static
) { container in
StatusAppFactory(
sessionService: container.resolve(),
authSessionService: container.resolve(),
networkClient: container.resolve(),
clipboardService: container.resolve()
)
}
container.register(
type: AuthAppFactory.self,
allocation: .static
) { container in
AuthAppFactory(
authSessionService: container.resolve(),
networkClient: container.resolve()
)
}
container.register(
type: AccountAppFactory.self,
allocation: .static
) { container in
AccountAppFactory(
sessionService: container.resolve()
)
}
container.register(
type: AccountUpdateAppFactory.self,
allocation: .static
) { container in
AccountUpdateAppFactory(
sessionService: container.resolve(),
authSessionService: container.resolve(),
networkClient: container.resolve()
)
}
container.register(
type: SidebarAppFactory.self,
allocation: .static
) { container in
SidebarAppFactory(
authSessionService: container.resolve()
)
}
container.register(
type: PURLsAppFactory.self,
allocation: .static
) { container in
PURLsAppFactory(
networkClient: container.resolve(),
authSessionService: container.resolve(),
sessionService: container.resolve(),
clipboardService: container.resolve()
)
}
container.register(
type: NowAppFactory.self,
allocation: .static
) { container in
NowAppFactory(
networkClient: container.resolve(),
authSessionService: container.resolve(),
sessionService: container.resolve()
)
}
container.register(
type: WebpageAppFactory.self,
allocation: .static
) { container in
WebpageAppFactory(
networkClient: container.resolve(),
authSessionService: container.resolve(),
sessionService: container.resolve()
)
}
container.register(
type: PastebinAppFactory.self,
allocation: .static
) { container in
PastebinAppFactory(
networkClient: container.resolve(),
authSessionService: container.resolve(),
sessionService: container.resolve(),
clipboardService: container.resolve()
)
}
container.register(
type: WeblogAppFactory.self,
allocation: .static
) { container in
WeblogAppFactory(
networkClient: container.resolve(),
authSessionService: container.resolve(),
sessionService: container.resolve(),
clipboardService: container.resolve()
)
}
container.register(
type: PicsAppFactory.self,
allocation: .static
) { container in
PicsAppFactory(
networkClient: container.resolve(),
authSessionService: container.resolve(),
sessionService: container.resolve(),
clipboardService: container.resolve()
)
}
}
}

86
OMG/TritonScene.swift Normal file
View file

@ -0,0 +1,86 @@
import AccountUpdateService
import Route
import Shortcuts
import SwiftUI
/// The primary scene that provides the main application window.
///
/// This scene creates the main application window with a navigation split view containing
/// a sidebar for feature selection and a detail view for the selected feature content.
/// It manages the application's primary navigation and integrates account update services.
struct TritonScene: Scene {
// MARK: - Properties
@State private var selection: RouteFeature? = .statuslog
@Environment(\.openWindow) private var openWindow
private let environment: any TritonEnvironmentProtocol
// MARK: - Lifecycle
init(
environment: any TritonEnvironmentProtocol
) {
self.environment = environment
}
// MARK: - Public
var body: some Scene {
WindowGroup(
MainWindow.name,
id: MainWindow.id
) {
NavigationSplitView(
sidebar: { makeSidebarView() },
detail: { makeDetailView() }
)
#if os(macOS)
.navigationTitle("Triton")
#endif
.environment(makeAccountUpdateService())
.handlesExternalEvents(
preferring: Set(arrayLiteral: "viewer"),
allowing: Set(arrayLiteral: "*")
)
.onAppear {
environment
.shortcutsService
.setUpObservers(openWindow: openWindow)
}
}
.commandsRemoved()
}
// MARK: - Private
@ViewBuilder
private func makeSidebarView() -> some View {
environment.sidebarAppFactory
.makeAppView(selection: $selection)
#if os(macOS)
.navigationSplitViewColumnWidth(
min: 150,
ideal: 150,
max: 200
)
#endif
}
@ViewBuilder
private func makeDetailView() -> some View {
DetailView(
environment: environment,
selectedFeature: $selection
)
#if os(macOS)
.frame(minWidth: 320, idealWidth: 480)
#endif
}
private func makeAccountUpdateService() -> AccountUpdateService {
environment.accountUpdateAppFactory
.makeAccountUpdateService()
}
}

122
OMG/Views/DetailView.swift Normal file
View file

@ -0,0 +1,122 @@
import DesignSystem
import Route
import SwiftUI
/// A SwiftUI view that displays the detail content for the selected application feature.
///
/// This view acts as the main content router in the application, switching between
/// different feature views based on the currently selected navigation item. It coordinates
/// with the app's dependency injection container to instantiate the appropriate feature
/// view through their respective app factories.
///
/// The view handles routing for all major application features including Statuslog,
/// PURLs, Account, Auth, Now page, Webpage, Pastebin, Weblog, and some.pics.
struct DetailView: View {
// MARK: - Properties
private let environment: any TritonEnvironmentProtocol
private var selectedFeature: Binding<RouteFeature?>
// MARK: - Lifecycle
init(
environment: any TritonEnvironmentProtocol,
selectedFeature: Binding<RouteFeature?>
) {
self.environment = environment
self.selectedFeature = selectedFeature
}
// MARK: - Public
var body: some View {
switch selectedFeature.wrappedValue {
case .statuslog:
makeStatusView()
case .purls:
makePURLsView()
case .account:
makeCurrentAccountView()
case .auth:
makeAuthView()
case .nowPage:
makeNowView()
case .webpage:
makeWebpageView()
case .pastebin:
makePastebinView()
case .weblog:
makeWeblogAppView()
case .somePics:
makePicsAppView()
default:
ContentUnavailableViewFactory.makeNotImplementedView()
}
}
// MARK: - Private
@ViewBuilder
private func makeCurrentAccountView() -> some View {
environment
.accountAppFactory
.makeAppView()
}
@ViewBuilder
private func makeStatusView() -> some View {
environment
.statusAppFactory
.makeAppView()
}
@ViewBuilder
private func makeAuthView() -> some View {
environment
.authAppFactory
.makeAppView()
}
@ViewBuilder
private func makeNowView() -> some View {
environment
.nowAppFactory
.makeAppView()
}
@ViewBuilder
private func makePURLsView() -> some View {
environment
.purlsAppFactory
.makeAppView()
}
@ViewBuilder
private func makeWebpageView() -> some View {
environment
.webpageAppFactory
.makeAppView()
}
@ViewBuilder
private func makePastebinView() -> some View {
environment
.pastebinAppFactory
.makeAppView()
}
@ViewBuilder
private func makeWeblogAppView() -> some View {
environment
.weblogAppFactory
.makeAppView()
}
@ViewBuilder
private func makePicsAppView() -> some View {
environment
.picsAppFactory
.makeAppView()
}
}

View file

@ -0,0 +1,61 @@
import DesignSystem
import Status
import SwiftUI
/// A SwiftUI view that displays the application settings interface.
///
/// This view provides a tabbed interface for managing various application settings
/// across different feature modules. It currently includes settings for the Statuslog
/// feature, with the capability to expand to additional feature settings tabs in the future.
///
/// The view uses the dependency injection container (`TritonEnvironment`) to access
/// feature-specific settings views through their respective app factories, maintaining
/// loose coupling between the main application and feature modules.
struct SettingsView: View {
// MARK: - Properties
private let environment: any TritonEnvironmentProtocol
// MARK: - Lifecycle
init(
environment: any TritonEnvironmentProtocol
) {
self.environment = environment
}
// MARK: - Public
var body: some View {
TabView {
makeStatusSettingsView()
.tabItem {
Label("Statuslog", systemImage: "message")
}
TipJarView()
.tabItem {
Label("Tip Jar", systemImage: "cup.and.saucer.fill")
}
}
.frame(width: 480)
}
// MARK: - Private
@ViewBuilder
private func makeStatusSettingsView() -> some View {
environment
.statusAppFactory
.makeSettingsView()
}
}
// MARK: - Preview
#Preview {
SettingsView(
environment: TritonEnvironment()
)
}

9
Packages/Account/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,51 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "Account",
platforms: [
.macOS(.v15),
.iOS(.v18)
],
products: [
.library(
name: "Account",
targets: ["Account"]
)
],
dependencies: [
.package(
name: "DesignSystem",
path: "../DesignSystem"
),
.package(
name: "SessionService",
path: "../SessionService"
),
.package(
name: "AuthSession",
path: "../AuthSession"
),
.package(
url: "https://github.com/otaviocc/MicroContainer.git",
from: "0.0.6"
),
.package(
url: "https://github.com/otaviocc/MicroClient.git",
from: "0.0.27"
)
],
targets: [
.target(
name: "Account",
dependencies: [
.product(name: "SessionServiceInterface", package: "SessionService"),
.product(name: "AuthSessionServiceInterface", package: "AuthSession"),
"DesignSystem",
"MicroContainer",
"MicroClient"
]
)
]
)

View file

@ -0,0 +1,35 @@
import MicroContainer
import SessionServiceInterface
struct AccountEnvironment {
// MARK: - Properties
var viewModelFactory: ViewModelFactory {
container.resolve()
}
private let container = DependencyContainer()
// MARK: - Lifecycle
init(
sessionService: any SessionServiceProtocol
) {
container.register(
type: (any SessionServiceProtocol).self,
allocation: .dynamic
) { _ in
sessionService
}
container.register(
type: ViewModelFactory.self,
allocation: .static
) { container in
ViewModelFactory(
container: container
)
}
}
}

View file

@ -0,0 +1,57 @@
import AuthSessionServiceInterface
import MicroClient
import SessionServiceInterface
import SwiftUI
/// Factory responsible for creating the account management feature and its views.
///
/// `AccountAppFactory` manages the account information display including user details,
/// addresses, and account settings. It initializes the account environment with required
/// dependencies and provides methods to create fully configured account views.
///
/// ## Usage
///
/// ```swift
/// let factory = AccountAppFactory(
/// sessionService: sessionService
/// )
///
/// let accountView = factory.makeAppView()
/// ```
public final class AccountAppFactory {
// MARK: - Properties
private let environment: AccountEnvironment
// MARK: - Lifecycle
public init(
sessionService: any SessionServiceProtocol
) {
environment = .init(
sessionService: sessionService
)
}
// MARK: - Public
/// Creates the main account management view.
///
/// This method constructs the account feature's root view with all necessary
/// dependencies injected. The view displays user account information including
/// email, addresses, registration date, and address management capabilities.
///
/// - Returns: A configured account view ready for presentation.
@MainActor
@ViewBuilder
public func makeAppView() -> some View {
let viewModel = environment.viewModelFactory
.makeAccountViewModel()
AccountView(
viewModel: viewModel
)
.environment(\.viewModelFactory, environment.viewModelFactory)
}
}

View file

@ -0,0 +1,51 @@
import MicroContainer
import SessionServiceInterface
import SwiftUI
final class ViewModelFactory: Sendable {
// MARK: - Properties
private let container: DependencyContainer
// MARK: - Lifecycle
init(
container: DependencyContainer
) {
self.container = container
}
// MARK: - Public
@MainActor
func makeAccountViewModel() -> AccountViewModel {
.init(
sessionService: container.resolve()
)
}
@MainActor
func makeAccountDetailsViewModel(
currentAccount: CurrentAccount
) -> AccountDetailsViewModel {
.init(
currentAccount: currentAccount,
sessionService: container.resolve()
)
}
}
// MARK: - Environment
extension EnvironmentValues {
@Entry var viewModelFactory: ViewModelFactory = .placeholder
}
extension ViewModelFactory {
static let placeholder = ViewModelFactory(
container: .init()
)
}

View file

@ -0,0 +1,21 @@
#if DEBUG
import Foundation
import SessionServiceInterface
enum AccountDetailsViewModelMother {
@MainActor
static func makeAccountDetailsViewModel() -> AccountDetailsViewModel {
.init(
currentAccount: CurrentAccountMother.makeCurrent(),
sessionService: SessionServiceMother.makeSessionService(
address: .address(
current: "otaviocc"
)
)
)
}
}
#endif

View file

@ -0,0 +1,16 @@
#if DEBUG
import SessionServiceInterface
enum AccountEnvironmentMother {
// MARK: - Public
static func makeAccountEnvironment() -> AccountEnvironment {
.init(
sessionService: SessionServiceMother.makeSessionService()
)
}
}
#endif

View file

@ -0,0 +1,31 @@
#if DEBUG
import Foundation
import SessionServiceInterface
enum CurrentAccountMother {
// MARK: - Public
static func makeCurrent() -> CurrentAccount {
.init(
name: "Otavio",
email: "foo@bar.abc",
creation: .distantPast,
addresses: [
.init(
address: "otaviocc",
creation: .distantPast,
expire: .distantFuture
),
.init(
address: "otavio",
creation: Date(),
expire: .distantFuture
)
]
)
}
}
#endif

View file

@ -0,0 +1,23 @@
#if DEBUG
import Foundation
import SessionServiceInterface
enum AccountViewModelMother {
// MARK: - Public
@MainActor
static func makeAccountViewModel(
account: Account
) -> AccountViewModel {
.init(
sessionService: SessionServiceMother
.makeSessionService(
account: account
)
)
}
}
#endif

View file

@ -0,0 +1,84 @@
import DesignSystem
import SessionServiceInterface
import SwiftUI
struct AccountView: View {
// MARK: - Properties
@State private var viewModel: AccountViewModel
@Environment(\.viewModelFactory) private var viewModelFactory
// MARK: - Lifecycle
init(
viewModel: AccountViewModel
) {
self.viewModel = viewModel
}
// MARK: - Public
var body: some View {
makeAccountView()
}
// MARK: - Private
@ViewBuilder
private func makeAccountView() -> some View {
switch viewModel.account {
case .notSynchronized:
EmptyView()
case let .account(current):
makeAccountDetailView(
currentAccount: current
)
}
}
@ViewBuilder
private func makeAccountDetailView(
currentAccount: CurrentAccount
) -> some View {
let viewModel = viewModelFactory
.makeAccountDetailsViewModel(
currentAccount: currentAccount
)
AccountDetailsView(
viewModel: viewModel
)
}
}
// MARK: - Preview
#if DEBUG
#Preview("No account") {
AccountView(
viewModel: AccountViewModelMother
.makeAccountViewModel(
account: .notSynchronized
)
)
.frame(width: 420)
}
#Preview("With account") {
let environment = AccountEnvironmentMother.makeAccountEnvironment()
AccountView(
viewModel: AccountViewModelMother
.makeAccountViewModel(
account: .account(
current: CurrentAccountMother.makeCurrent()
)
)
)
.frame(width: 420)
.environment(\.viewModelFactory, environment.viewModelFactory)
}
#endif

View file

@ -0,0 +1,42 @@
import Observation
import SessionServiceInterface
@MainActor
@Observable
final class AccountViewModel {
// MARK: - Properties
private(set) var account: Account = .notSynchronized
private let sessionService: any SessionServiceProtocol
@ObservationIgnored private var observationTask: Task<Void, Never>?
// MARK: - Lifecycle
init(
sessionService: any SessionServiceProtocol
) {
self.sessionService = sessionService
setUpObservers()
}
deinit {
observationTask?.cancel()
}
// MARK: - Private
private func setUpObservers() {
observationTask = Task { [weak self] in
guard let self else { return }
for await account in sessionService.observeAccount() {
await MainActor.run {
self.account = account
}
}
}
}
}

View file

@ -0,0 +1,147 @@
import DesignSystem
import SessionServiceInterface
import SwiftUI
struct AccountDetailsView: View {
// MARK: - Properties
@State private var viewModel: AccountDetailsViewModel
// MARK: - Lifecycle
init(
viewModel: AccountDetailsViewModel
) {
self.viewModel = viewModel
}
// MARK: - Public
var body: some View {
Form {
makeHeaderView()
makeAddressesView()
}
.scrollContentBackground(.hidden)
.padding(8)
.formStyle(.columns)
}
// MARK: - Private
@ViewBuilder
private func makeHeaderView() -> some View {
let registrationDate = viewModel
.currentAccount
.creation
.formatted(
date: .long,
time: .omitted
)
Section("Account") {
HStack {
VStack(alignment: .leading) {
Text(viewModel.currentAccount.name)
.bold()
.foregroundColor(.primary)
Text(viewModel.currentAccount.email)
.foregroundColor(.secondary)
Text("Registered \(registrationDate)")
.foregroundColor(.secondary)
}
}
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.card(.omgBackground)
}
}
@ViewBuilder
private func makeAddressesView() -> some View {
Section("Addresses") {
List {
ForEach(viewModel.currentAccount.addresses, id: \.address) { address in
HStack {
AvatarView(address: address.address)
makeAddressView(address: address)
}
}
}
}
}
@ViewBuilder
private func makeAddressView(
address: CurrentAccount.Address
) -> some View {
let registrationDate = address
.creation
.formatted(
.relative(
presentation: .named,
unitsStyle: .wide
)
)
let formattedExpire = if let expire = address.expire {
"Expires \(expire.formatted(.relative(presentation: .named, unitsStyle: .wide)))"
} else {
"🌟 Lifetime address"
}
VStack(alignment: .leading) {
HStack {
VStack(alignment: .leading) {
Text(address.address)
.bold()
.foregroundColor(.primary)
Text("Registered \(registrationDate)")
.font(.subheadline)
.foregroundColor(.secondary)
Text(formattedExpire)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if address.address == viewModel.selectedAddress {
Text("Current")
.textCase(.uppercase)
.font(.subheadline)
} else {
Button {
withAnimation {
viewModel.selectAddress(address.address)
}
} label: {
Text("Use This")
.textCase(.uppercase)
.font(.subheadline)
}
.help("Switch to this address")
.buttonStyle(.borderedProminent)
}
}
}
}
}
// MARK: - Preview
#if DEBUG
#Preview {
AccountDetailsView(
viewModel: AccountDetailsViewModelMother.makeAccountDetailsViewModel()
)
.frame(width: 420)
}
#endif

View file

@ -0,0 +1,66 @@
import Foundation
import Observation
import SessionServiceInterface
@MainActor
@Observable
final class AccountDetailsViewModel {
// MARK: - Properties
let currentAccount: CurrentAccount
private(set) var selectedAddress: String?
private let sessionService: any SessionServiceProtocol
@ObservationIgnored private var observationTask: Task<Void, Never>?
// MARK: - Lifecycle
init(
currentAccount: CurrentAccount,
sessionService: any SessionServiceProtocol
) {
self.currentAccount = currentAccount
self.sessionService = sessionService
setUpObservers()
}
deinit {
observationTask?.cancel()
}
// MARK: - Public
func selectAddress(
_ address: String
) {
Task {
await sessionService.setSelectedAddress(
address
)
}
}
// MARK: - Private
private func setUpObservers() {
observationTask = Task { [weak self] in
guard let self else { return }
for await address in sessionService.observeAddress() {
let selected: String? = switch address {
case let .address(current):
current
default:
nil
}
await MainActor.run {
self.selectedAddress = selected
}
}
}
}
}

View file

@ -0,0 +1,30 @@
import SwiftUI
/// Main view for the Account feature.
///
/// `AccountApp` is the root view that displays account information including
/// user details, addresses, and account management capabilities. It coordinates
/// the account feature's UI components and view models.
public struct AccountApp: View {
// MARK: - Properties
@State private var viewModel: AccountAppViewModel
@Environment(\.viewModelFactory) private var viewModelFactory
// MARK: - Lifecycle
init(
viewModel: AccountAppViewModel
) {
self.viewModel = viewModel
}
// MARK: - Public
public var body: some View {
AccountView(
viewModel: viewModelFactory.makeAccountViewModel()
)
}
}

View file

@ -0,0 +1,5 @@
import Observation
@MainActor
@Observable
final class AccountAppViewModel {}

9
Packages/AccountUpdate/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,72 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "AccountUpdate",
platforms: [
.macOS(.v15),
.iOS(.v18)
],
products: [
.library(
name: "AccountUpdateService",
targets: ["AccountUpdateService"]
)
],
dependencies: [
.package(
name: "SessionService",
path: "../SessionService"
),
.package(
name: "OMGAPI",
path: "../OMGAPI"
),
.package(
name: "AuthSession",
path: "../AuthSession"
),
.package(
url: "https://github.com/otaviocc/MicroContainer.git",
from: "0.0.6"
),
.package(
url: "https://github.com/apple/swift-async-algorithms",
from: "1.0.0"
)
],
targets: [
.target(
name: "AccountUpdateService",
dependencies: [
"AccountUpdateNetworkService",
"AccountUpdatePersistenceService",
"AccountUpdateRepository",
"MicroContainer"
]
),
.target(
name: "AccountUpdateNetworkService",
dependencies: [
"OMGAPI"
]
),
.target(
name: "AccountUpdatePersistenceService",
dependencies: [
.product(name: "AuthSessionServiceInterface", package: "AuthSession"),
.product(name: "SessionServiceInterface", package: "SessionService")
]
),
.target(
name: "AccountUpdateRepository",
dependencies: [
.product(name: "AuthSessionServiceInterface", package: "AuthSession"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
"AccountUpdateNetworkService",
"AccountUpdatePersistenceService"
]
)
]
)

View file

@ -0,0 +1,101 @@
import Foundation
import MicroClient
import OMGAPI
/// A protocol for network operations related to account information updates.
///
/// This protocol defines the interface for fetching account information and associated
/// addresses from the remote server. It provides the network layer functionality needed
/// to keep local account information synchronized with the server state.
///
/// The protocol supports fetching both core account information (name, email, creation date)
/// and associated addresses (domains) with their registration and expiration details.
/// This information is typically used to populate user profile displays and manage
/// domain/address ownership information.
public protocol AccountUpdateNetworkServiceProtocol: Sendable {
/// Fetches the current account information from the server.
///
/// This method retrieves the authenticated user's account details including
/// their name, email address, and account creation date. The information
/// is fetched from the server's account information endpoint.
///
/// - Returns: An `AccountResponse` containing the user's account information.
/// - Throws: Network errors, authentication errors, or API errors if the fetch fails.
func fetchAccount() async throws -> AccountResponse
/// Fetches all addresses associated with the current account.
///
/// This method retrieves the list of addresses that are registered
/// to the authenticated user's account. Each address includes registration
/// and expiration dates, providing complete ownership information.
///
/// - Returns: An array of `AddressResponse` objects representing the user's addresses.
/// - Throws: Network errors, authentication errors, or API errors if the fetch fails.
func fetchAddresses() async throws -> [AddressResponse]
}
actor AccountUpdateNetworkService: AccountUpdateNetworkServiceProtocol {
// MARK: - Properties
private let networkClient: NetworkClientProtocol
// MARK: - Lifecycle
init(
networkClient: NetworkClientProtocol
) {
self.networkClient = networkClient
}
// MARK: - Public
func fetchAccount() async throws -> AccountResponse {
let accountResponse = try await networkClient.run(
AccountRequestFactory.makeAccountInformationRequest()
)
return AccountResponse(
response: accountResponse.value.response
)
}
func fetchAddresses() async throws -> [AddressResponse] {
let addressesResponse = try await networkClient.run(
AccountRequestFactory.makeAccountAddressesRequest()
)
return addressesResponse.value.response.map(AddressResponse.init)
}
}
// MARK: - Private
private extension AccountResponse {
/// Initializes the `AccountResponse` model from the network response
/// model, so that the client doesn't depend on network models.
/// - Parameter response: The network model to be mapped.
init(
response: AccountInformationResponse.Response
) {
name = response.name
email = response.email
unixEpochTime = response.created.unixEpochTime
}
}
private extension AddressResponse {
/// Initializes the `AddressResponse` model from the network response
/// model, so that the client doesn't depend on network models.
/// - Parameter response: The network model to be mapped.
init(
response: AccountAddressesResponse.AccountAddressResponse
) {
address = response.address
unixEpochTime = response.registration.unixEpochTime
expireUnixEpochTime = response.expiration.unixEpochTime
}
}

View file

@ -0,0 +1,57 @@
import MicroClient
/// A protocol for creating account update network service instances.
///
/// This protocol defines the factory interface for creating properly configured
/// account update network services with their required HTTP client dependencies.
/// The factory pattern abstracts the complex initialization of network services
/// and enables dependency injection of different network client implementations.
///
/// Account update network services handle HTTP requests for account-related operations
/// including profile updates, settings modifications, and account information retrieval.
/// Implementations should configure the service with appropriate network clients
/// for API communication with account management endpoints.
///
/// ## Usage Example
/// ```swift
/// let factory: AccountUpdateNetworkServiceFactoryProtocol = AccountUpdateNetworkServiceFactory()
/// let service = factory.makeAccountUpdateNetworkService(networkClient: networkClient)
/// ```
public protocol AccountUpdateNetworkServiceFactoryProtocol {
/// Creates a new account update network service instance.
///
/// This method constructs a fully configured account update network service with
/// the provided HTTP client. The service handles account-related network operations
/// including profile updates, settings changes, and account information synchronization.
///
/// The created service provides:
/// - Account profile update operations via HTTP APIs
/// - Account settings modification and retrieval
/// - User preference synchronization with remote endpoints
/// - Account information validation and submission
/// - Proper error handling for network failures and validation errors
///
/// - Parameter networkClient: The network client used to perform HTTP requests.
/// - Returns: A configured `AccountUpdateNetworkServiceProtocol` instance ready for use.
func makeAccountUpdateNetworkService(
networkClient: NetworkClientProtocol
) -> AccountUpdateNetworkServiceProtocol
}
public struct AccountUpdateNetworkServiceFactory: AccountUpdateNetworkServiceFactoryProtocol {
// MARK: - Lifecycle
public init() {}
// MARK: - Public
public func makeAccountUpdateNetworkService(
networkClient: NetworkClientProtocol
) -> AccountUpdateNetworkServiceProtocol {
AccountUpdateNetworkService(
networkClient: networkClient
)
}
}

View file

@ -0,0 +1,55 @@
/// A data transfer object representing account information from the network API.
///
/// This model serves as the network layer's representation of user account data,
/// specifically designed to match the API response format. It acts as an intermediary
/// between the raw network response and the application's domain models.
///
/// The `AccountResponse` uses Unix epoch time for the creation timestamp to maintain
/// compatibility with the API format, while the application layer converts this to
/// Foundation's Date type for easier manipulation.
///
/// ## Usage Example
/// ```swift
/// // Typically created by the network service when parsing API responses
/// let accountResponse = AccountResponse(
/// email: "user@example.com",
/// name: "John Doe",
/// unixEpochTime: 1640995200
/// )
///
/// // Converted to domain model by repository layer
/// let account = Account(
/// name: accountResponse.name,
/// email: accountResponse.email,
/// creation: Date(timeIntervalSince1970: Double(accountResponse.unixEpochTime)),
/// addresses: []
/// )
/// ```
///
/// ## Data Flow
/// AccountResponse flows through the system as follows:
/// 1. Network service receives raw API response
/// 2. Response is parsed into AccountResponse DTO
/// 3. Repository layer converts to domain Account model
/// 4. Domain model is stored in session state
public struct AccountResponse: Sendable {
/// The user's email address as returned by the API.
///
/// This represents the primary email associated with the user's account
/// and is used for identification and communication purposes.
public let email: String
/// The user's display name as returned by the API.
///
/// This is how the user identifies themselves within the service
/// and appears in various UI elements throughout the application.
public let name: String
/// The account creation timestamp in Unix epoch time format.
///
/// This value represents the number of seconds since January 1, 1970 UTC
/// when the user's account was originally created. The repository layer
/// converts this to Foundation's Date type for use in the application.
public let unixEpochTime: Int
}

View file

@ -0,0 +1,67 @@
/// A data transfer object representing address information from the network API.
///
/// This model serves as the network layer's representation of user address data,
/// specifically designed to match the API response format for address listings. It acts
/// as an intermediary between the raw network response and the application's domain models.
///
/// The `AddressResponse` uses Unix epoch time for both registration and expiration
/// timestamps to maintain compatibility with the API format, while the application
/// layer converts these to Foundation's Date type for easier date manipulation and
/// comparison operations.
///
/// ## Usage Example
/// ```swift
/// // Regular address with expiration date
/// let temporaryAddress = AddressResponse(
/// address: "alice",
/// unixEpochTime: 1640995200,
/// expireUnixEpochTime: 1672531200
/// )
///
/// // Lifetime address with no expiration
/// let lifetimeAddress = AddressResponse(
/// address: "lifetime-user",
/// unixEpochTime: 1640995200,
/// expireUnixEpochTime: nil
/// )
///
/// // Converted to domain model by repository layer
/// let address = Account.Address(
/// address: addressResponse.address,
/// creation: Date(timeIntervalSince1970: Double(addressResponse.unixEpochTime)),
/// expire: addressResponse.expireUnixEpochTime.map { Date(timeIntervalSince1970: Double($0)) }
/// )
/// ```
///
/// ## Data Flow
/// AddressResponse flows through the system as follows:
/// 1. Network service receives raw API response array
/// 2. Each response item is parsed into AddressResponse DTO
/// 3. Repository layer converts to domain Account.Address models
/// 4. Domain models are included in Account and stored in session state
public struct AddressResponse: Sendable {
/// The address string as returned by the API (e.g., "alice").
///
/// This represents the username that the user can use for communication,
/// web presence, or other services provided by the platform.
public let address: String
/// The address registration timestamp in Unix epoch time format.
///
/// This value represents the number of seconds since January 1, 1970 UTC
/// when the address was originally registered by the user. The repository
/// layer converts this to Foundation's Date type for use in the application.
public let unixEpochTime: Int
/// The address expiration timestamp in Unix epoch time format, if the address has an expiration date.
///
/// This optional value represents the number of seconds since January 1, 1970 UTC
/// when the address registration expires. A `nil` value indicates a lifetime address
/// that never expires. This information is crucial for renewal management and
/// determining address availability status. The repository layer converts this to
/// Foundation's Date type for easier comparison with the current date.
///
/// - Note: Lifetime addresses return `nil` for this property and do not require renewal.
public let expireUnixEpochTime: Int?
}

View file

@ -0,0 +1,110 @@
import AuthSessionServiceInterface
import SessionServiceInterface
/// A protocol for managing account information persistence operations.
///
/// This protocol defines the interface for storing account information in the application's
/// session state. Unlike traditional persistence services that use databases or file storage,
/// this service manages account data within the active session through the SessionService.
///
/// The service handles the integration between fetched account data and the session management
/// system, ensuring that current account information is available throughout the application.
/// It also manages automatic cleanup of account data when users log out.
///
/// All storage operations are performed on the main actor to ensure thread safety with
/// session state updates and UI synchronization.
public protocol AccountUpdatePersistenceServiceProtocol: Sendable {
/// Stores account information in the current session.
///
/// This method persists the provided account information by updating the current
/// session with the account details. The account data becomes available throughout
/// the application via the session service and can be accessed by UI components
/// and other services that need account information.
///
/// The account information includes user details (name, email) and associated
/// addresses with their registration and expiration dates.
///
/// - Parameter account: The account information to store in the current session.
/// - Throws: Session-related errors if the storage operation fails.
@MainActor
func storeAccount(_ account: Account) async throws
}
actor AccountUpdatePersistenceService: AccountUpdatePersistenceServiceProtocol {
// MARK: - Properties
private let sessionService: any SessionServiceProtocol
private let authSessionService: any AuthSessionServiceProtocol
private var logoutObservationTask: Task<Void, Never>?
// MARK: - Lifecycle
init(
sessionService: any SessionServiceProtocol,
authSessionService: any AuthSessionServiceProtocol
) {
self.sessionService = sessionService
self.authSessionService = authSessionService
Task { await setUpObservers() }
}
deinit {
logoutObservationTask?.cancel()
}
// MARK: - Public
@MainActor
func storeAccount(
_ account: Account
) async throws {
await sessionService
.setCurrentAccount(
.init(account: account)
)
}
// MARK: - Private
private func setUpObservers() async {
logoutObservationTask = Task {
for await _ in authSessionService.observeLogoutEvents() {
await sessionService.clearSession()
}
}
}
}
// MARK: - Private
private extension CurrentAccount {
/// Initializes `CurrentAccount` from an `Account`.
///
/// This extension is file private since no other part of the codebase
/// needs to know how to map between these two types.
///
/// - Parameter account: The account to be mapped.
init(
account: Account
) {
let addresses = account.addresses
.map { address in
CurrentAccount.Address(
address: address.address,
creation: address.creation,
expire: address.expire
)
}
self.init(
name: account.name,
email: account.email,
creation: account.creation,
addresses: addresses
)
}
}

View file

@ -0,0 +1,67 @@
import AuthSessionServiceInterface
import SessionServiceInterface
/// A protocol for creating account update persistence service instances.
///
/// This protocol defines the factory interface for creating properly configured
/// account update persistence services with their required session service dependencies.
/// The factory pattern abstracts the complex initialization of persistence services
/// and enables dependency injection of different session service implementations.
///
/// Account update persistence services handle local storage and caching of account
/// update operations, temporary data management during account modification workflows,
/// and integration with session state for user context. Implementations should configure
/// the service with appropriate session services for state management and user context.
///
/// ## Usage Example
/// ```swift
/// let factory: AccountUpdatePersistenceServiceFactoryProtocol = AccountUpdatePersistenceServiceFactory()
/// let service = factory.makeAccountUpdatePersistenceService(
/// sessionService: sessionService,
/// authSessionService: authSessionService
/// )
/// ```
public protocol AccountUpdatePersistenceServiceFactoryProtocol {
/// Creates a new account update persistence service instance.
///
/// This method constructs a fully configured account update persistence service
/// with the provided session services. The persistence service handles local
/// storage of account update operations, draft management, and integration with
/// user session state for context-aware account modifications.
///
/// The created service provides:
/// - Local storage of account update drafts and pending changes
/// - Session-aware account modification workflows
/// - Integration with user authentication state for secure operations
/// - Temporary data management during multi-step account updates
/// - Cache management for account-related data and preferences
///
/// - Parameters:
/// - sessionService: The session service for user state management.
/// - authSessionService: The authentication session service for user context.
/// - Returns: A configured `AccountUpdatePersistenceServiceProtocol` instance ready for use.
func makeAccountUpdatePersistenceService(
sessionService: any SessionServiceProtocol,
authSessionService: any AuthSessionServiceProtocol
) -> AccountUpdatePersistenceServiceProtocol
}
public final class AccountUpdatePersistenceServiceFactory: AccountUpdatePersistenceServiceFactoryProtocol {
// MARK: - Lifecycle
public init() {}
// MARK: - Public
public func makeAccountUpdatePersistenceService(
sessionService: any SessionServiceProtocol,
authSessionService: any AuthSessionServiceProtocol
) -> AccountUpdatePersistenceServiceProtocol {
AccountUpdatePersistenceService(
sessionService: sessionService,
authSessionService: authSessionService
)
}
}

View file

@ -0,0 +1,187 @@
import Foundation
/// Represents complete account information including user details and associated addresses.
///
/// This model serves as the central data structure for user account information within
/// the application's session state. It combines core user details (name, email, creation date)
/// with all associated addresses/domains that belong to the account.
///
/// The `Account` struct is designed to be immutable and thread-safe (Sendable), making it
/// suitable for use across different actors and concurrent operations. It's also Codable
/// to support serialization for network communication and potential caching scenarios.
///
/// ## Usage Example
/// ```swift
/// let account = Account(
/// name: "John Doe",
/// email: "john@example.com",
/// creation: Date(),
/// addresses: [
/// // Regular address with expiration
/// Account.Address(
/// address: "john",
/// creation: Date(),
/// expire: Calendar.current.date(byAdding: .year, value: 1, to: Date())!
/// ),
/// // Lifetime address with no expiration
/// Account.Address(
/// address: "lifetime-john",
/// creation: Date(),
/// expire: nil
/// )
/// ]
/// )
/// ```
///
/// ## Data Flow
/// Account data flows through the system as follows:
/// 1. Network layer fetches account and address data separately
/// 2. Repository combines the responses into this unified Account model
/// 3. Persistence service stores the account in the session state
/// 4. UI components access the account information through the session service
public struct Account: Codable, Sendable {
// MARK: - Properties
/// The user's display name as registered with their account.
///
/// This is the name that appears in user interface elements and represents
/// how the user identifies themselves within the service.
public let name: String
/// The user's primary email address associated with their account.
///
/// This email serves as the primary identifier for the account and is used
/// for communication, account recovery, and authentication purposes.
public let email: String
/// The date when the user's account was originally created.
///
/// This timestamp provides historical information about the account age
/// and can be used for analytics, user journey tracking, or feature eligibility.
public let creation: Date
/// An array of all addresses associated with this account.
///
/// Each address represents a username that the user owns or has
/// registered through the service. This includes both active and expired addresses,
/// allowing the application to display complete ownership history and manage
/// renewal notifications.
public let addresses: [Address]
// MARK: - Lifecycle
/// Creates a new Account instance with the specified user information and addresses.
///
/// This initializer is typically used by the repository layer when combining
/// network responses into a unified account model, or by test code when creating
/// mock account data.
///
/// - Parameters:
/// - name: The user's display name
/// - email: The user's primary email address
/// - creation: The date when the account was originally created
/// - addresses: An array of addresses/domains associated with this account
public init(
name: String,
email: String,
creation: Date,
addresses: [Account.Address]
) {
self.name = name
self.email = email
self.creation = creation
self.addresses = addresses
}
}
public extension Account {
// MARK: - Nested types
/// Represents an address associated with a user account.
///
/// An address in this context represents a username that the user has
/// registered through the service (e.g., "alice"). Each address includes
/// registration and expiration information, allowing the application to track ownership
/// status and manage renewal workflows.
///
/// The Address struct is designed to be immutable and thread-safe, making it suitable
/// for use in concurrent operations and actor-based architectures. It's also Codable
/// to support serialization alongside its parent Account model.
///
/// ## Usage Example
/// ```swift
/// // Regular address with expiration
/// let regularAddress = Account.Address(
/// address: "alice",
/// creation: Date(),
/// expire: Calendar.current.date(byAdding: .year, value: 1, to: Date())!
/// )
///
/// // Lifetime address with no expiration
/// let lifetimeAddress = Account.Address(
/// address: "lifetime-alice",
/// creation: Date(),
/// expire: nil
/// )
///
/// // Check if address is still valid (lifetime addresses are always valid)
/// let isActive = regularAddress.expire?.compare(Date()) == .orderedDescending || regularAddress.expire == nil
/// ```
///
/// ## Address Lifecycle
/// 1. Address is registered by the user through the service
/// 2. Registration information is fetched from the API
/// 3. Address data is stored as part of the user's account
/// 4. Application can display ownership status and renewal reminders based on expiration
struct Address: Codable, Sendable {
// MARK: - Properties
/// The actual address string (e.g., "alice").
///
/// This represents the username that the user can use for communication,
/// web presence, or other services provided by the platform.
public let address: String
/// The date when this address was originally registered.
///
/// This timestamp provides historical information about when the user
/// first acquired this address, useful for account history and analytics.
public let creation: Date
/// The date when this address registration expires, if it has an expiration date.
///
/// This optional timestamp is crucial for renewal management, allowing the application
/// to display warnings, send notifications, or restrict features as the
/// expiration date approaches. A `nil` value indicates a lifetime address that never
/// expires and does not require renewal. Regular addresses past their expiration
/// date may become unavailable for use.
///
/// - Note: Lifetime addresses return `nil` and remain active indefinitely.
public let expire: Date?
// MARK: - Lifecycle
/// Creates a new Address instance with the specified address information.
///
/// This initializer is typically used by the repository layer when mapping
/// network response data into domain models, or by test code when creating
/// mock address data.
///
/// - Parameters:
/// - address: The address string (e.g., "alice")
/// - creation: The date when this address was registered
/// - expire: The date when this address registration expires
public init(
address: String,
creation: Date,
expire: Date?
) {
self.address = address
self.creation = creation
self.expire = expire
}
}
}

View file

@ -0,0 +1,155 @@
import AccountUpdateNetworkService
import AccountUpdatePersistenceService
import AsyncAlgorithms
import AuthSessionServiceInterface
import Foundation
/// A repository protocol for managing automatic account information updates.
///
/// This protocol defines the interface for coordinating automatic synchronization of
/// account information from the remote server to local storage. The repository handles
/// the complex orchestration of periodic updates, authentication state monitoring,
/// and data persistence.
///
/// Unlike other repositories that provide explicit methods for data operations, this
/// repository operates automatically in the background, triggered by authentication
/// events and periodic timers. It ensures that account information stays current
/// without requiring manual intervention from higher-level components.
///
/// The repository follows the repository pattern while providing a completely automated
/// approach to account data synchronization, handling network fetching, data
/// transformation, and secure storage seamlessly.
public protocol AccountUpdateRepositoryProtocol: Sendable {}
actor AccountUpdateRepository: AccountUpdateRepositoryProtocol {
// MARK: - Properties
private let networkService: AccountUpdateNetworkServiceProtocol
private let persistenceService: AccountUpdatePersistenceServiceProtocol
private let authSessionService: any AuthSessionServiceProtocol
private var loginObservationTask: Task<Void, Never>?
private var timerObservationTask: Task<Void, Never>?
// MARK: - Lifecycle
init(
networkService: AccountUpdateNetworkServiceProtocol,
persistenceService: AccountUpdatePersistenceServiceProtocol,
authSessionService: any AuthSessionServiceProtocol
) {
self.networkService = networkService
self.persistenceService = persistenceService
self.authSessionService = authSessionService
Task { await setUpObservers() }
}
deinit {
loginObservationTask?.cancel()
timerObservationTask?.cancel()
}
// MARK: - Private
private func setUpObservers() {
timerObservationTask = Task { [weak self] in
guard let self else { return }
await fetchAccountInformation()
for await _ in AsyncTimerSequence(interval: .seconds(3600), clock: .continuous) {
await fetchAccountInformation()
}
}
loginObservationTask = Task { [weak self] in
guard let self else { return }
if await authSessionService.isLoggedIn {
await fetchAccountInformation()
}
for await isLoggedIn in authSessionService.observeLoginState() {
if isLoggedIn {
await fetchAccountInformation()
}
}
}
}
private func fetchAccountInformation() async {
guard await authSessionService.isLoggedIn else { return }
do {
let accountResponse = try await networkService.fetchAccount()
let addressesResponse = try await networkService.fetchAddresses()
let account = Account(
accountResponse: accountResponse,
addressesResponse: addressesResponse
)
do {
try await persistenceService.storeAccount(account)
} catch {
print("Failed to persist account: \(error)")
}
} catch {
print("Account update failed: \(error)")
}
}
}
// MARK: - Private
private extension Account {
/// Initializes an `Account` with the data from two network requests:
/// one which returns the account information, and another one which
/// returns the addresses associated with the user account.
///
/// This initializer handles both regular addresses (with expiration dates) and
/// lifetime addresses (without expiration dates). The optional expiration timestamp
/// from the network response is mapped to an optional Date, with `nil` values
/// indicating lifetime addresses that never expire.
///
/// This extension is file private since no other part of the codebase
/// needs to know how to map the network response types and the
/// type which is persisted.
///
/// - Parameters:
/// - accountResponse: The network model for the account information.
/// - addressesResponse: The network model for the addresses information, including optional expiration dates.
init(
accountResponse: AccountResponse,
addressesResponse: [AddressResponse]
) {
let accountDate = Date(
timeIntervalSince1970: accountResponse.unixEpochTime.doubleValue
)
let addresses = addressesResponse.map { addressResponse in
let addressDate = Date(
timeIntervalSince1970: addressResponse.unixEpochTime.doubleValue
)
let expireDate = addressResponse.expireUnixEpochTime
.map(Double.init)
.map(Date.init(timeIntervalSince1970:))
return Account.Address(
address: addressResponse.address,
creation: addressDate,
expire: expireDate
)
}
self.init(
name: accountResponse.name,
email: accountResponse.email,
creation: accountDate,
addresses: addresses
)
}
}

View file

@ -0,0 +1,7 @@
extension Int {
/// Returns a double from integer.
var doubleValue: Double {
.init(self)
}
}

View file

@ -0,0 +1,77 @@
import AccountUpdateNetworkService
import AccountUpdatePersistenceService
import AuthSessionServiceInterface
/// A protocol for creating account update repository instances.
///
/// This protocol defines the factory interface for creating properly configured
/// account update repositories with their required network, persistence, and session
/// service dependencies. The factory pattern abstracts the complex initialization
/// of repositories and enables dependency injection of different service implementations.
///
/// Account update repositories coordinate between network, persistence, and authentication
/// layers to provide a unified interface for account management operations. They handle
/// account profile updates, settings modifications, data synchronization, and provide
/// seamless integration between remote API calls and local data storage for account
/// information and user preferences.
///
/// ## Usage Example
/// ```swift
/// let factory: AccountUpdateRepositoryFactoryProtocol = AccountUpdateRepositoryFactory()
/// let repository = factory.makeAccountUpdateRepository(
/// networkService: networkService,
/// persistenceService: persistenceService,
/// authSessionService: authSessionService
/// )
/// ```
public protocol AccountUpdateRepositoryFactoryProtocol {
/// Creates a new account update repository instance.
///
/// This method constructs a fully configured account update repository with the
/// provided services. The repository coordinates between network, persistence,
/// and authentication services to provide comprehensive account management
/// capabilities including profile updates, settings synchronization, and user
/// context management.
///
/// The created repository provides:
/// - Account profile updates with optimistic UI updates and rollback support
/// - Settings synchronization between local preferences and remote storage
/// - Draft management for multi-step account modification workflows
/// - Real-time synchronization between local cache and remote account data
/// - User authentication state integration for secure operations
/// - Conflict resolution for concurrent account modifications
/// - Timer-based periodic synchronization for account data freshness
///
/// - Parameters:
/// - networkService: The network service for remote account update operations.
/// - persistenceService: The persistence service for local data storage and caching.
/// - authSessionService: The authentication session service for user context.
/// - Returns: A configured `AccountUpdateRepositoryProtocol` instance ready for use.
func makeAccountUpdateRepository(
networkService: AccountUpdateNetworkServiceProtocol,
persistenceService: AccountUpdatePersistenceServiceProtocol,
authSessionService: any AuthSessionServiceProtocol
) -> AccountUpdateRepositoryProtocol
}
public final class AccountUpdateRepositoryFactory: AccountUpdateRepositoryFactoryProtocol {
// MARK: - Lifecycle
public init() {}
// MARK: - Public
public func makeAccountUpdateRepository(
networkService: AccountUpdateNetworkServiceProtocol,
persistenceService: AccountUpdatePersistenceServiceProtocol,
authSessionService: any AuthSessionServiceProtocol
) -> AccountUpdateRepositoryProtocol {
AccountUpdateRepository(
networkService: networkService,
persistenceService: persistenceService,
authSessionService: authSessionService
)
}
}

View file

@ -0,0 +1,18 @@
import AccountUpdateRepository
import Observation
@Observable
public final class AccountUpdateService {
// MARK: - Properties
private let updateRepository: AccountUpdateRepositoryProtocol
// MARK: - Lifecycle
init(
updateRepository: AccountUpdateRepositoryProtocol
) {
self.updateRepository = updateRepository
}
}

View file

@ -0,0 +1,98 @@
import AccountUpdateNetworkService
import AccountUpdatePersistenceService
import AccountUpdateRepository
import AuthSessionServiceInterface
import MicroClient
import MicroContainer
import SessionServiceInterface
struct AccountUpdateEnvironment {
// MARK: - Properties
var accountUpdateRepository: AccountUpdateRepositoryProtocol {
container.resolve()
}
private let container = DependencyContainer()
// MARK: - Lifecycle
init(
sessionService: any SessionServiceProtocol,
authSessionService: any AuthSessionServiceProtocol,
networkClient: NetworkClientProtocol
) {
self.init(
sessionService: sessionService,
authSessionService: authSessionService,
networkClient: networkClient,
networkServiceFactory: AccountUpdateNetworkServiceFactory(),
persistenceServiceFactory: AccountUpdatePersistenceServiceFactory(),
serviceFactory: AccountUpdateRepositoryFactory()
)
}
init(
sessionService: any SessionServiceProtocol,
authSessionService: any AuthSessionServiceProtocol,
networkClient: NetworkClientProtocol,
networkServiceFactory: AccountUpdateNetworkServiceFactoryProtocol,
persistenceServiceFactory: AccountUpdatePersistenceServiceFactoryProtocol,
serviceFactory: AccountUpdateRepositoryFactoryProtocol
) {
container.register(
type: (any SessionServiceProtocol).self,
allocation: .dynamic
) { _ in
sessionService
}
container.register(
type: (any AuthSessionServiceProtocol).self,
allocation: .dynamic
) { _ in
authSessionService
}
container.register(
type: NetworkClientProtocol.self,
allocation: .dynamic
) { _ in
networkClient
}
container.register(
type: AccountUpdateNetworkServiceProtocol.self,
allocation: .static
) { container in
networkServiceFactory
.makeAccountUpdateNetworkService(
networkClient: container.resolve()
)
}
container.register(
type: AccountUpdatePersistenceServiceProtocol.self,
allocation: .static
) { container in
persistenceServiceFactory
.makeAccountUpdatePersistenceService(
sessionService: container.resolve(),
authSessionService: container.resolve()
)
}
container.register(
type: AccountUpdateRepositoryProtocol.self,
allocation: .static
) { container in
serviceFactory
.makeAccountUpdateRepository(
networkService: container.resolve(),
persistenceService: container.resolve(),
authSessionService: container.resolve()
)
}
}
}

View file

@ -0,0 +1,57 @@
import AccountUpdateRepository
import AuthSessionServiceInterface
import MicroClient
import SessionServiceInterface
/// Factory responsible for creating account update services and dependencies.
///
/// `AccountUpdateAppFactory` manages the account update functionality including
/// fetching and syncing current account information. It initializes the account update
/// environment with required dependencies and provides methods to create account update services.
///
/// ## Usage
///
/// ```swift
/// let factory = AccountUpdateAppFactory(
/// sessionService: sessionService,
/// authSessionService: authSession,
/// networkClient: client
/// )
///
/// let updateService = factory.makeAccountUpdateService()
/// ```
public final class AccountUpdateAppFactory {
// MARK: - Properties
private let environment: AccountUpdateEnvironment
// MARK: - Lifecycle
public init(
sessionService: any SessionServiceProtocol,
authSessionService: any AuthSessionServiceProtocol,
networkClient: NetworkClientProtocol
) {
environment = .init(
sessionService: sessionService,
authSessionService: authSessionService,
networkClient: networkClient
)
}
// MARK: - Public
/// Creates an account update service.
///
/// This method constructs an account update service with all necessary dependencies
/// injected. The service handles fetching current account information from the API
/// and updating the session with the latest account data.
///
/// - Returns: A configured account update service ready for use.
public func makeAccountUpdateService() -> AccountUpdateService {
.init(
updateRepository: environment.accountUpdateRepository
)
}
}

9
Packages/Auth/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,80 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "Auth",
platforms: [
.macOS(.v15),
.iOS(.v18)
],
products: [
.library(
name: "Auth",
targets: ["Auth"]
)
],
dependencies: [
.package(
name: "DesignSystem",
path: "../DesignSystem"
),
.package(
name: "OMGAPI",
path: "../OMGAPI"
),
.package(
name: "AuthSession",
path: "../AuthSession"
),
.package(
url: "https://github.com/otaviocc/MicroClient.git",
from: "0.0.27"
),
.package(
url: "https://github.com/otaviocc/MicroContainer.git",
from: "0.0.6"
)
],
targets: [
.target(
name: "Auth",
dependencies: [
.product(name: "AuthSessionServiceInterface", package: "AuthSession"),
"AuthRepository",
"DesignSystem",
"MicroContainer"
]
),
.target(
name: "AuthNetworkService",
dependencies: ["OMGAPI", "MicroClient"]
),
.testTarget(
name: "AuthNetworkServiceTests",
dependencies: ["AuthNetworkService"]
),
.target(
name: "AuthPersistenceService",
dependencies: [
.product(name: "AuthSessionServiceInterface", package: "AuthSession")
]
),
.testTarget(
name: "AuthPersistenceServiceTests",
dependencies: ["AuthPersistenceService"]
),
.target(
name: "AuthRepository",
dependencies: [
.product(name: "AuthSessionServiceInterface", package: "AuthSession"),
"AuthNetworkService",
"AuthPersistenceService"
]
),
.testTarget(
name: "AuthRepositoryTests",
dependencies: ["AuthRepository"]
)
]
)

View file

@ -0,0 +1,92 @@
import AuthNetworkService
import AuthPersistenceService
import AuthRepository
import AuthSessionServiceInterface
import MicroClient
import MicroContainer
struct AuthEnvironment {
// MARK: - Properties
var viewModelFactory: ViewModelFactory { container.resolve() }
private let container = DependencyContainer()
// MARK: - Lifecycle
init(
authSessionService: any AuthSessionServiceProtocol,
networkClient: NetworkClientProtocol
) {
self.init(
repositoryFactory: AuthRepositoryFactory(),
networkServiceFactory: AuthNetworkServiceFactory(),
persistenceServiceFactory: AuthPersistenceServiceFactory(),
authSessionService: authSessionService,
networkClient: networkClient
)
}
init(
repositoryFactory: AuthRepositoryFactoryProtocol,
networkServiceFactory: AuthNetworkServiceFactoryProtocol,
persistenceServiceFactory: AuthPersistenceServiceFactoryProtocol,
authSessionService: any AuthSessionServiceProtocol,
networkClient: NetworkClientProtocol
) {
container.register(
type: (any AuthSessionServiceProtocol).self,
allocation: .dynamic
) { _ in
authSessionService
}
container.register(
type: NetworkClientProtocol.self,
allocation: .dynamic
) { _ in
networkClient
}
container.register(
type: AuthNetworkServiceProtocol.self,
allocation: .static
) { container in
networkServiceFactory
.makeAuthNetworkService(
networkClient: container.resolve()
)
}
container.register(
type: AuthPersistenceServiceProtocol.self,
allocation: .static
) { container in
persistenceServiceFactory
.makeAuthPersistenceService(
authSessionService: container.resolve()
)
}
container.register(
type: AuthRepositoryProtocol.self,
allocation: .static
) { container in
repositoryFactory
.makeAuthRepository(
networkService: container.resolve(),
persistenceService: container.resolve()
)
}
container.register(
type: ViewModelFactory.self,
allocation: .static
) { container in
ViewModelFactory(
container: container
)
}
}
}

View file

@ -0,0 +1,59 @@
import AuthSessionServiceInterface
import MicroClient
import SwiftUI
/// Factory responsible for creating the authentication feature and its views.
///
/// `AuthAppFactory` manages the authentication flow including login and logout functionality.
/// It initializes the authentication environment with required dependencies and provides
/// methods to create fully configured authentication views.
///
/// ## Usage
///
/// ```swift
/// let factory = AuthAppFactory(
/// authSessionService: authSession,
/// networkClient: client
/// )
///
/// let authView = factory.makeAppView()
/// ```
public final class AuthAppFactory {
// MARK: - Properties
private let environment: AuthEnvironment
// MARK: - Lifecycle
public init(
authSessionService: any AuthSessionServiceProtocol,
networkClient: NetworkClientProtocol
) {
environment = .init(
authSessionService: authSessionService,
networkClient: networkClient
)
}
// MARK: - Public
/// Creates the main authentication view.
///
/// This method constructs the authentication feature's root view with all necessary
/// dependencies injected. The view handles the complete authentication flow including
/// login, logout, and session management.
///
/// - Returns: A configured authentication view ready for presentation.
@MainActor
@ViewBuilder
public func makeAppView() -> some View {
let viewModel = environment.viewModelFactory
.makeAuthAppViewModel()
AuthApp(
viewModel: viewModel
)
.environment(\.viewModelFactory, environment.viewModelFactory)
}
}

View file

@ -0,0 +1,56 @@
import AuthRepository
import Foundation
import MicroContainer
import SwiftUI
final class ViewModelFactory: Sendable {
// MARK: - Properties
private let container: DependencyContainer
// MARK: - Lifecycle
init(
container: DependencyContainer
) {
self.container = container
}
// MARK: - Public
@MainActor
func makeAuthAppViewModel() -> AuthAppViewModel {
.init(
authSessionService: container.resolve()
)
}
@MainActor
func makeLoginViewModel() -> LoginViewModel {
.init(
repository: container.resolve()
)
}
@MainActor
func makeLogoutViewModel() -> LogoutViewModel {
.init(
repository: container.resolve()
)
}
}
// MARK: - Environment
extension EnvironmentValues {
@Entry var viewModelFactory: ViewModelFactory = .placeholder
}
extension ViewModelFactory {
static let placeholder = ViewModelFactory(
container: .init()
)
}

View file

@ -0,0 +1,31 @@
#if DEBUG
import AuthRepository
import Foundation
enum LoginViewModelMother {
// MARK: - Nested types
private final class FakeAuthRepository: AuthRepositoryProtocol {
func handleDeepLinkURL(_ url: URL) async throws {}
func storeToken(accessToken: String) {}
func removeAccessToken() {}
func accessToken() async throws -> String {
"some access token"
}
}
// MARK: - Public
@MainActor
static func makeLoginViewModel() -> LoginViewModel {
.init(
repository: FakeAuthRepository()
)
}
}
#endif

View file

@ -0,0 +1,31 @@
#if DEBUG
import AuthRepository
import Foundation
enum LogoutViewModelMother {
// MARK: - Nested types
private final class FakeAuthRepository: AuthRepositoryProtocol {
func handleDeepLinkURL(_ url: URL) async throws {}
func storeToken(accessToken: String) {}
func removeAccessToken() {}
func accessToken() async throws -> String {
"some access token"
}
}
// MARK: - Public
@MainActor
static func makeLogoutViewModel() -> LogoutViewModel {
.init(
repository: FakeAuthRepository()
)
}
}
#endif

View file

@ -0,0 +1,36 @@
import SwiftUI
/// Main view for the Authentication feature.
///
/// `AuthApp` is the root view that manages the authentication flow, displaying
/// either the login view when the user is not authenticated or the logout view
/// when authenticated. It coordinates the authentication state and view transitions.
public struct AuthApp: View {
// MARK: - Properties
@State private var viewModel: AuthAppViewModel
@Environment(\.viewModelFactory) private var viewModelFactory
// MARK: - Lifecycle
init(
viewModel: AuthAppViewModel
) {
self.viewModel = viewModel
}
// MARK: - Public
public var body: some View {
if viewModel.isLoggedIn {
LogoutView(
viewModel: viewModelFactory.makeLogoutViewModel()
)
} else {
LoginView(
viewModel: viewModelFactory.makeLoginViewModel()
)
}
}
}

View file

@ -0,0 +1,39 @@
import AuthSessionServiceInterface
import Observation
import SwiftUI
@MainActor
@Observable
final class AuthAppViewModel {
// MARK: - Properties
private(set) var isLoggedIn = false
private let authSessionService: any AuthSessionServiceProtocol
@ObservationIgnored private var observationTask: Task<Void, Never>?
// MARK: - Lifecycle
init(
authSessionService: any AuthSessionServiceProtocol
) {
self.authSessionService = authSessionService
observationTask = Task { @MainActor in
let currentState = await authSessionService.isLoggedIn
isLoggedIn = currentState
for await loginState in authSessionService.observeLoginState() {
if isLoggedIn != loginState {
isLoggedIn = loginState
}
}
}
}
deinit {
observationTask?.cancel()
}
}

View file

@ -0,0 +1,55 @@
import DesignSystem
import SwiftUI
struct LoginView: View {
// MARK: - Properties
@State private var viewModel: LoginViewModel
@Environment(\.openURL) private var openURL
// MARK: - Lifecycle
init(
viewModel: LoginViewModel
) {
self.viewModel = viewModel
}
// MARK: - Public
var body: some View {
VStack {
Image(systemName: "heart.circle.fill")
.font(.system(size: 56))
.frame(width: 60, height: 60)
Text("Hi there! Please log in to continue using the app.")
.font(.body)
Button {
openURL(viewModel.codeRequestURL)
} label: {
Text("Log in")
}
}
.frame(.full)
.padding()
.onOpenURL { url in
viewModel.handleDeeplinkURL(url)
}
}
}
// MARK: - Preview
#if DEBUG
#Preview {
LoginView(
viewModel: LoginViewModelMother.makeLoginViewModel()
)
.frame(width: 300, height: 300)
}
#endif

View file

@ -0,0 +1,38 @@
import AuthRepository
import Foundation
import Observation
import OMGAPI
@MainActor
@Observable
final class LoginViewModel {
// MARK: - Properties
private let repository: AuthRepositoryProtocol
// MARK: - Computed Properties
var codeRequestURL: URL {
AuthRequestFactory.makeOAuthCodeRequestURL()!
}
// MARK: - Lifecycle
init(
repository: AuthRepositoryProtocol
) {
self.repository = repository
}
// MARK: - Public
func handleDeeplinkURL(
_ url: URL
) {
Task { [weak self] in
guard let self else { return }
try await repository.handleDeepLinkURL(url)
}
}
}

View file

@ -0,0 +1,51 @@
import DesignSystem
import SwiftUI
struct LogoutView: View {
// MARK: - Properties
@State private var viewModel: LogoutViewModel
// MARK: - Lifecycle
init(
viewModel: LogoutViewModel
) {
self.viewModel = viewModel
}
// MARK: - Public
var body: some View {
VStack {
Image(systemName: "heart.circle")
.font(.system(size: 56))
.frame(width: 60, height: 60)
Text("I'm sorry to see you go :-(")
.font(.body)
Button {
viewModel.logout()
} label: {
Text("Log out")
}
}
.frame(.full)
.padding()
}
}
// MARK: - Preview
#if DEBUG
#Preview {
LogoutView(
viewModel: LogoutViewModelMother.makeLogoutViewModel()
)
.frame(width: 300, height: 300)
}
#endif

View file

@ -0,0 +1,28 @@
import AuthRepository
import Observation
import SwiftUI
@MainActor
@Observable
final class LogoutViewModel {
// MARK: - Properties
private let repository: AuthRepositoryProtocol
// MARK: - Lifecycle
init(
repository: AuthRepositoryProtocol
) {
self.repository = repository
}
// MARK: - Public
func logout() {
Task {
await repository.removeAccessToken()
}
}
}

View file

@ -0,0 +1,64 @@
import MicroClient
import OMGAPI
/// A protocol for network operations related to authentication token exchange.
///
/// This protocol defines the interface for handling OAuth-style authentication flows,
/// specifically the exchange of authorization codes for access tokens. It manages the
/// network communication with the authentication server to complete the OAuth flow
/// after the user has been redirected back to the application with an authorization code.
///
/// The protocol handles the critical step in OAuth flows where a temporary authorization
/// code (typically received via URL callback or deep link) is exchanged for a persistent
/// access token that can be used for authenticated API requests.
public protocol AuthNetworkServiceProtocol: AnyObject, Sendable {
/// Exchanges an authorization code for an access token.
///
/// This method implements the OAuth "authorization code" flow by sending the
/// authorization code to the authentication server and receiving an access token
/// in response. The authorization code is typically obtained when the user
/// completes the authentication process and is redirected back to the application.
///
/// The access token returned by this method can be used for subsequent authenticated
/// API requests and should be securely stored for future use.
///
/// This is a critical security operation that must be performed over HTTPS to
/// protect the authorization code and access token during transmission.
///
/// - Parameter code: The authorization code received from the OAuth callback/redirect.
/// - Returns: The access token that can be used for authenticated requests.
/// - Throws: Network errors, authentication errors, or API validation errors if the code exchange fails.
func accessToken(
code: String
) async throws -> String
}
actor AuthNetworkService: AuthNetworkServiceProtocol {
// MARK: - Properties
private let networkClient: NetworkClientProtocol
// MARK: - Lifecycle
init(
networkClient: NetworkClientProtocol
) {
self.networkClient = networkClient
}
// MARK: - Public
func accessToken(
code: String
) async throws -> String {
let request = AuthRequestFactory.makeAuthRequest(
code: code
)
let response = try await networkClient.run(request)
return response.value.accessToken
}
}

View file

@ -0,0 +1,55 @@
import MicroClient
/// A protocol for creating authentication network service instances.
///
/// This protocol defines the factory interface for creating properly configured
/// authentication network services with their required HTTP client dependencies.
/// The factory pattern abstracts the complex initialization of network services
/// and enables dependency injection of different network client implementations.
///
/// Authentication network services handle OAuth-related HTTP requests including
/// authorization code exchange for access tokens. Implementations should configure
/// the service with appropriate network clients for API communication.
///
/// ## Usage Example
/// ```swift
/// let factory: AuthNetworkServiceFactoryProtocol = AuthNetworkServiceFactory()
/// let service = factory.makeAuthNetworkService(networkClient: networkClient)
/// ```
public protocol AuthNetworkServiceFactoryProtocol {
/// Creates a new authentication network service instance.
///
/// This method constructs a fully configured authentication network service
/// with the provided HTTP client. The service handles OAuth token exchange
/// requests and other authentication-related network operations.
///
/// The created service provides:
/// - OAuth authorization code to access token exchange
/// - HTTP client abstraction for network operations
/// - Proper error handling for authentication failures
/// - Integration with the broader authentication system
///
/// - Parameter networkClient: The network client for performing HTTP requests.
/// - Returns: A configured `AuthNetworkServiceProtocol` instance ready for use.
func makeAuthNetworkService(
networkClient: NetworkClientProtocol
) -> AuthNetworkServiceProtocol
}
public struct AuthNetworkServiceFactory: AuthNetworkServiceFactoryProtocol {
// MARK: - Lifecycle
public init() {}
// MARK: - Public
public func makeAuthNetworkService(
networkClient: NetworkClientProtocol
) -> AuthNetworkServiceProtocol {
AuthNetworkService(
networkClient: networkClient
)
}
}

View file

@ -0,0 +1,82 @@
import AuthSessionServiceInterface
import Foundation
/// A protocol for managing secure persistence of authentication tokens.
///
/// This protocol defines the interface for secure storage and retrieval of access tokens
/// using the device's secure storage mechanisms. It acts as an abstraction layer over
/// the AuthSessionService, providing a clean interface for authentication-related
/// persistence operations.
///
/// The service ensures that access tokens are stored securely using the device's Keychain,
/// providing protection against unauthorized access and ensuring tokens persist across
/// app launches and system reboots. All token operations are performed asynchronously
/// to maintain responsive UI performance.
///
/// This persistence layer integrates with the broader authentication system to provide
/// reactive token state management and automatic cleanup during logout operations.
public protocol AuthPersistenceServiceProtocol: AnyObject, Sendable {
/// Retrieves the currently stored access token.
///
/// This method fetches the access token that was previously stored in secure storage.
/// The token is retrieved from the device's Keychain through the authentication
/// session service, ensuring secure access to sensitive authentication data.
///
/// - Returns: The stored access token, or `nil` if no token is available.
func fetchAccessToken() async -> String?
/// Securely stores an access token.
///
/// This method saves the provided access token to secure storage using the device's
/// Keychain. The token will persist across app launches and be available for
/// authenticated API requests until explicitly removed or replaced.
///
/// Storing a token automatically updates the authentication state, triggering
/// notifications to components that observe authentication state changes.
///
/// - Parameter value: The access token to store securely.
func storeAccessToken(value: String) async
/// Removes the stored access token and logs out the user.
///
/// This method securely removes the access token from storage, effectively
/// logging out the user. The token is permanently deleted from the device's
/// Keychain and cannot be recovered.
///
/// Removing a token automatically updates the authentication state to unauthenticated
/// and triggers logout events for components that observe authentication changes,
/// enabling automatic cleanup of user data and UI state.
func removeAccessToken() async
}
actor AuthPersistenceService: AuthPersistenceServiceProtocol {
// MARK: - Properties
private let authSessionService: any AuthSessionServiceProtocol
// MARK: - Lifecycle
init(
authSessionService: any AuthSessionServiceProtocol
) {
self.authSessionService = authSessionService
}
// MARK: - Public
func fetchAccessToken() async -> String? {
await authSessionService.accessToken
}
func storeAccessToken(
value: String
) async {
await authSessionService.setAccessToken(value)
}
func removeAccessToken() async {
await authSessionService.setAccessToken(nil)
}
}

View file

@ -0,0 +1,55 @@
import AuthSessionServiceInterface
/// A protocol for creating authentication persistence service instances.
///
/// This protocol defines the factory interface for creating properly configured
/// authentication persistence services with their required session service dependencies.
/// The factory pattern abstracts the complex initialization of persistence services
/// and enables dependency injection of different session service implementations.
///
/// Authentication persistence services handle secure storage and retrieval of OAuth
/// tokens and authentication state using the device's Keychain. Implementations
/// should configure the service with appropriate session services for state management.
///
/// ## Usage Example
/// ```swift
/// let factory: AuthPersistenceServiceFactoryProtocol = AuthPersistenceServiceFactory()
/// let service = factory.makeAuthPersistenceService(authSessionService: sessionService)
/// ```
public protocol AuthPersistenceServiceFactoryProtocol {
/// Creates a new authentication persistence service instance.
///
/// This method constructs a fully configured authentication persistence service
/// with the provided session service. The persistence service handles secure
/// storage of OAuth tokens and authentication state in the device's Keychain.
///
/// The created service provides:
/// - Secure token storage using Keychain Services
/// - OAuth token lifecycle management (store, retrieve, remove)
/// - Integration with session state management
/// - Proper error handling for storage operations
///
/// - Parameter authSessionService: The session service for managing authentication state.
/// - Returns: A configured `AuthPersistenceServiceProtocol` instance ready for use.
func makeAuthPersistenceService(
authSessionService: any AuthSessionServiceProtocol
) -> AuthPersistenceServiceProtocol
}
public struct AuthPersistenceServiceFactory: AuthPersistenceServiceFactoryProtocol {
// MARK: - Lifecycle
public init() {}
// MARK: - Public
public func makeAuthPersistenceService(
authSessionService: any AuthSessionServiceProtocol
) -> AuthPersistenceServiceProtocol {
AuthPersistenceService(
authSessionService: authSessionService
)
}
}

View file

@ -0,0 +1,153 @@
import AuthNetworkService
import AuthPersistenceService
import Foundation
/// Errors that can occur during authentication repository operations.
///
/// This enumeration defines the specific error cases that can arise when
/// working with authentication tokens and repository operations.
public enum AuthRepositoryError: Error {
/// Indicates that no access token is available when one is required.
///
/// This error is thrown when attempting to retrieve an access token
/// but none has been stored, typically indicating the user is not
/// authenticated or the token has been removed/expired.
case missingToken
}
/// A repository protocol for managing authentication flow and token storage.
///
/// This protocol defines the interface for coordinating the complete authentication
/// process, from handling OAuth callbacks through secure token storage. It manages
/// the integration between network-based authentication operations and secure local
/// token persistence.
///
/// The repository handles the OAuth "authorization code" flow where users are redirected
/// to an external authentication provider, then redirected back to the application with
/// an authorization code that must be exchanged for an access token. It also provides
/// secure storage and retrieval of access tokens using the device's Keychain.
///
/// The repository follows the repository pattern, abstracting the complexities of
/// OAuth flows and secure storage to provide a clean interface for authentication
/// management throughout the application.
public protocol AuthRepositoryProtocol: Sendable {
/// Handles OAuth callback URLs containing authorization codes.
///
/// This method processes deep link URLs that result from OAuth authentication flows.
/// When users complete authentication with the external provider, they are redirected
/// back to the application with a URL containing an authorization code. This method:
///
/// 1. Parses the URL to extract the authorization code
/// 2. Validates that the URL is an authentication callback (host == "authenticate")
/// 3. Exchanges the code for an access token via the network service
/// 4. Securely stores the access token using the persistence service
///
/// The method silently returns if the URL is not a valid authentication callback,
/// allowing other URL handlers to process non-authentication deep links.
///
/// - Parameter url: The deep link URL containing the OAuth authorization code.
/// - Throws: Network errors from the token exchange or validation errors if the flow fails.
func handleDeepLinkURL(
_ url: URL
) async throws
/// Stores an access token securely.
///
/// This method provides a direct way to store an access token, typically used
/// in scenarios where a token is obtained through means other than the standard
/// OAuth deep link flow (such as manual token entry or alternative authentication methods).
///
/// The token is stored securely using the same mechanism as tokens obtained
/// through the OAuth flow, ensuring consistent security practices.
///
/// - Parameter accessToken: The access token to store securely.
func storeToken(
accessToken: String
) async
/// Retrieves the currently stored access token.
///
/// This method returns the access token that was previously stored through
/// authentication flows. The token can be used for authenticated API requests.
///
/// - Returns: The stored access token.
/// - Throws: `AuthRepositoryError.missingToken` if no token is available.
func accessToken() async throws -> String
/// Removes the stored access token and logs out the user.
///
/// This method securely removes the access token from storage, effectively
/// logging out the user. After calling this method, subsequent calls to
/// `accessToken()` will throw `AuthRepositoryError.missingToken` until
/// the user authenticates again.
///
/// This operation also triggers logout events for components that observe
/// authentication state changes.
func removeAccessToken() async
}
actor AuthRepository: AuthRepositoryProtocol {
// MARK: - Properties
private let networkService: AuthNetworkServiceProtocol
private let persistenceService: AuthPersistenceServiceProtocol
// MARK: - Lifecycle
init(
networkService: AuthNetworkServiceProtocol,
persistenceService: AuthPersistenceServiceProtocol
) {
self.networkService = networkService
self.persistenceService = persistenceService
}
// MARK: - Public
func handleDeepLinkURL(
_ url: URL
) async throws {
let components = URLComponents(
url: url,
resolvingAgainstBaseURL: true
)
let host = components?.host
let code = components?.queryItems?
.first { $0.name == "code" }?
.value
guard host == "authenticate", let code else { return }
let accessToken = try await networkService.accessToken(
code: code
)
await persistenceService.storeAccessToken(
value: accessToken
)
}
func storeToken(
accessToken: String
) async {
await persistenceService.storeAccessToken(
value: accessToken
)
}
func accessToken() async throws -> String {
guard let accessToken = await persistenceService.fetchAccessToken() else {
throw AuthRepositoryError.missingToken
}
return accessToken
}
func removeAccessToken() async {
await persistenceService.removeAccessToken()
}
}

View file

@ -0,0 +1,55 @@
import AuthNetworkService
import AuthPersistenceService
/// A protocol for creating authentication repository instances.
///
/// This protocol defines the factory interface for creating properly configured
/// authentication repositories with their required dependencies. The factory pattern
/// abstracts the complex initialization of authentication repositories and their
/// network and persistence service dependencies.
///
/// Implementations should configure the repository with appropriate network services
/// for OAuth token exchange and persistence services for secure token storage.
public protocol AuthRepositoryFactoryProtocol {
/// Creates a new authentication repository instance.
///
/// This method constructs a fully configured authentication repository with
/// the provided network and persistence services. The repository coordinates
/// between these services to provide complete OAuth authentication flow
/// management and secure token storage.
///
/// The created repository handles:
/// - OAuth authorization code exchange via the network service
/// - Secure token storage and retrieval via the persistence service
/// - Deep link URL parsing for authentication callbacks
/// - Token lifecycle management (store, retrieve, remove)
///
/// - Parameters:
/// - networkService: The network service for OAuth token exchange operations.
/// - persistenceService: The persistence service for secure token storage.
/// - Returns: A configured `AuthRepositoryProtocol` instance ready for use.
func makeAuthRepository(
networkService: AuthNetworkServiceProtocol,
persistenceService: AuthPersistenceServiceProtocol
) -> AuthRepositoryProtocol
}
public struct AuthRepositoryFactory: AuthRepositoryFactoryProtocol {
// MARK: - Lifecycle
public init() {}
// MARK: - Public
public func makeAuthRepository(
networkService: AuthNetworkServiceProtocol,
persistenceService: AuthPersistenceServiceProtocol
) -> AuthRepositoryProtocol {
AuthRepository(
networkService: networkService,
persistenceService: persistenceService
)
}
}

View file

@ -0,0 +1,4 @@
import XCTest
@testable import AuthNetworkService
final class AuthNetworkServiceTests: XCTestCase {}

View file

@ -0,0 +1,4 @@
import XCTest
@testable import AuthPersistenceService
final class AuthPersistenceServiceTests: XCTestCase {}

View file

@ -0,0 +1,4 @@
import XCTest
@testable import AuthRepository
final class AuthRepositoryTests: XCTestCase {}

9
Packages/AuthSession/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,36 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "AuthSession",
platforms: [
.macOS(.v15),
.iOS(.v18)
],
products: [
.library(
name: "AuthSessionService",
targets: ["AuthSessionService"]
),
.library(
name: "AuthSessionServiceInterface",
targets: ["AuthSessionServiceInterface"]
)
],
dependencies: [],
targets: [
.target(
name: "AuthSessionServiceInterface",
dependencies: []
),
.target(
name: "AuthSessionService",
dependencies: ["AuthSessionServiceInterface"]
),
.testTarget(
name: "AuthSessionServiceTests",
dependencies: ["AuthSessionService"]
)
]
)

View file

@ -0,0 +1,123 @@
import AuthSessionServiceInterface
import Foundation
public actor AuthSessionService: AuthSessionServiceProtocol {
// MARK: - Properties
private var _accessToken: String?
private var _isLoggedIn: Bool
private let keychainStore: KeychainStoreProtocol
private let loginStateContinuation: AsyncStream<Bool>.Continuation
private let logoutEventContinuation: AsyncStream<Void>.Continuation
private var loginStateObservers: [AsyncStream<Bool>.Continuation] = []
private var logoutEventObservers: [AsyncStream<Void>.Continuation] = []
public nonisolated let logoutEventStream: AsyncStream<Void>
public var isLoggedIn: Bool {
_isLoggedIn
}
public var accessToken: String? {
_accessToken
}
// MARK: - Lifecycle
init(
keychainStore: KeychainStoreProtocol
) {
self.keychainStore = keychainStore
let token = keychainStore.wrappedValue
_accessToken = token
_isLoggedIn = token != nil
var loginContinuation: AsyncStream<Bool>.Continuation!
_ = AsyncStream<Bool> { continuation in
loginContinuation = continuation
}
loginStateContinuation = loginContinuation
var logoutContinuation: AsyncStream<Void>.Continuation!
var logoutStream: AsyncStream<Void>!
logoutStream = AsyncStream<Void> { continuation in
logoutContinuation = continuation
}
logoutEventStream = logoutStream
logoutEventContinuation = logoutContinuation
}
// MARK: - Public
public func setAccessToken(_ token: String?) async {
let previousToken = _accessToken
_accessToken = token
keychainStore.wrappedValue = token
let newLoginState = token != nil
if _isLoggedIn != newLoginState {
_isLoggedIn = newLoginState
for observer in loginStateObservers {
observer.yield(newLoginState)
}
}
if previousToken != nil, token == nil {
for observer in logoutEventObservers {
observer.yield()
}
}
}
public nonisolated func observeLoginState() -> AsyncStream<Bool> {
AsyncStream { continuation in
Task {
let currentState = await self.isLoggedIn
continuation.yield(currentState)
await self.addLoginStateObserver(continuation)
continuation.onTermination = { _ in
Task {
await self.removeLoginStateObserver(continuation)
}
}
}
}
}
public nonisolated func observeLogoutEvents() -> AsyncStream<Void> {
AsyncStream { continuation in
Task {
await self.addLogoutEventObserver(continuation)
continuation.onTermination = { _ in
Task {
await self.removeLogoutEventObserver(continuation)
}
}
}
}
}
private func addLoginStateObserver(_ continuation: AsyncStream<Bool>.Continuation) {
loginStateObservers.append(continuation)
}
private func removeLoginStateObserver(_ continuation: AsyncStream<Bool>.Continuation) {
// Note: AsyncStream.Continuation cleanup is handled by the onTermination callback
// A more robust solution might involve wrapping continuations in identifiable wrappers
}
private func addLogoutEventObserver(_ continuation: AsyncStream<Void>.Continuation) {
logoutEventObservers.append(continuation)
}
private func removeLogoutEventObserver(_ continuation: AsyncStream<Void>.Continuation) {
// Note: AsyncStream.Continuation cleanup is handled by the onTermination callback
// A more robust solution might involve wrapping continuations in identifiable wrappers
}
}

View file

@ -0,0 +1,48 @@
import AuthSessionServiceInterface
import Foundation
/// A protocol for creating authentication session service instances.
///
/// This protocol defines the factory interface for creating properly configured
/// authentication session services with secure Keychain storage. The factory pattern
/// abstracts the complex initialization of the authentication service and its dependencies,
/// providing a clean interface for dependency injection and testing.
///
/// Implementations should configure the service with appropriate secure storage
/// mechanisms and any required dependencies for authentication state management.
public protocol AuthSessionServiceFactoryProtocol {
/// Creates a new authentication session service instance.
///
/// This method constructs a fully configured authentication session service
/// with secure Keychain storage for access tokens. The service is initialized
/// with any existing tokens from secure storage and is ready for immediate use.
///
/// The created service handles:
/// - Secure token storage and retrieval via Keychain
/// - Authentication state management
/// - Reactive streams for login state and logout events
/// - Automatic token persistence across app launches
///
/// - Returns: A configured `AuthSessionServiceProtocol` instance ready for use.
func makeAuthSessionService() -> any AuthSessionServiceProtocol
}
public struct AuthSessionServiceFactory: AuthSessionServiceFactoryProtocol {
// MARK: - Lifecycle
public init() {}
// MARK: - Public
public func makeAuthSessionService() -> any AuthSessionServiceProtocol {
let store = KeychainStore(
"Triton: Access Token"
)
return AuthSessionService(
keychainStore: store
)
}
}

View file

@ -0,0 +1,19 @@
#if DEBUG
enum KeychainStoreMother {
// MARK: - Nested types
private final class FakeKeychainStore: KeychainStoreProtocol, @unchecked Sendable {
var wrappedValue: String?
}
// MARK: - Public
static func makeKeychainStore() -> KeychainStoreProtocol {
FakeKeychainStore()
}
}
#endif

View file

@ -0,0 +1,129 @@
import Foundation
protocol KeychainStoreProtocol: AnyObject, Sendable {
/// Value stored in the Keychain.
var wrappedValue: String? { get set }
}
final class KeychainStore: KeychainStoreProtocol, @unchecked Sendable {
// MARK: - Nested types
typealias ItemDeleter = (
CFDictionary
) -> OSStatus
typealias ItemAdder = (
CFDictionary,
UnsafeMutablePointer<CFTypeRef?>?
) -> OSStatus
typealias ItemCopyMatcher = (
CFDictionary,
UnsafeMutablePointer<CFTypeRef?>?
) -> OSStatus
// MARK: - Properties
var wrappedValue: String? {
get {
guard let data = load(key: key) else {
return nil
}
return String(data: data, encoding: .utf8)
}
set {
guard
let value = newValue,
let data = value.data(using: .utf8)
else {
delete(key)
return
}
save(key: key, data: data)
}
}
private let key: String
private let itemDeleter: ItemDeleter
private let itemAdder: ItemAdder
private let itemCopyMatcher: ItemCopyMatcher
// MARK: - Lifecycle
init(
_ key: String,
itemDeleter: @escaping ItemDeleter = SecItemDelete,
itemAdder: @escaping ItemAdder = SecItemAdd,
itemCopyMatcher: @escaping ItemCopyMatcher = SecItemCopyMatching
) {
self.key = key
self.itemDeleter = itemDeleter
self.itemAdder = itemAdder
self.itemCopyMatcher = itemCopyMatcher
}
// MARK: - Private
@discardableResult
private func save(
key: String,
data: Data
) -> OSStatus {
let query = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
] as [String: Any]
_ = itemDeleter(
query as CFDictionary
)
return itemAdder(
query as CFDictionary,
nil
)
}
private func load(
key: String
) -> Data? {
let query = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
] as [String: Any]
var data: AnyObject?
let status = itemCopyMatcher(
query as CFDictionary,
&data
)
if status == noErr {
return data as? Data
} else {
return nil
}
}
@discardableResult
private func delete(
_ key: String
) -> OSStatus {
let query = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
] as [String: Any]
return itemDeleter(
query as CFDictionary
)
}
}

View file

@ -0,0 +1,76 @@
import Foundation
/// A protocol for managing authentication session state and secure token storage.
///
/// This protocol defines the interface for handling user authentication sessions,
/// including secure access token management and reactive authentication state monitoring.
/// It provides a centralized way to manage authentication state across the application
/// with secure Keychain storage and reactive streams for state changes.
///
/// The service handles the complete authentication lifecycle, from initial login through
/// logout, with automatic cleanup of sensitive data and notification of authentication
/// state changes to dependent components.
///
/// All operations are async-safe and designed to work seamlessly with SwiftUI and
/// other reactive components that need to respond to authentication state changes.
public protocol AuthSessionServiceProtocol: Sendable {
/// The current access token used for authenticated network requests.
///
/// This token is securely stored in the device's Keychain and is used to authenticate
/// API requests. The token is automatically loaded from secure storage on app startup
/// and persisted across app launches. Returns `nil` when the user is not authenticated.
///
/// The token should be included in network request headers for authenticated endpoints.
var accessToken: String? { get async }
/// The current authentication state of the user.
///
/// This computed property returns `true` when a valid access token is available,
/// indicating the user is authenticated and can make authenticated requests.
/// Returns `false` when no token is available or the user has logged out.
///
/// This property is reactive and its changes trigger updates to observers
/// via the `observeLoginState()` stream.
var isLoggedIn: Bool { get async }
/// Updates the access token and authentication state.
///
/// This method securely stores the provided token in the Keychain and updates
/// the authentication state accordingly. When a token is provided, the user
/// becomes authenticated. When `nil` is provided, the user is logged out and
/// the token is removed from secure storage.
///
/// Setting the token automatically triggers notifications to observers:
/// - Login state observers receive the new authentication state
/// - Logout event observers are notified when transitioning from authenticated to unauthenticated
///
/// - Parameter token: The access token to store, or `nil` to log out and clear the token.
func setAccessToken(_ token: String?) async
/// Provides a stream of authentication state changes.
///
/// This method returns an AsyncStream that emits the current authentication state
/// immediately upon subscription, followed by updates whenever the authentication
/// state changes (login or logout events).
///
/// The stream is useful for reactive UI components that need to update based on
/// authentication state, such as showing/hiding login screens or authenticated content.
///
/// - Returns: An `AsyncStream<Bool>` that emits `true` for authenticated state and `false` for unauthenticated
/// state.
func observeLoginState() -> AsyncStream<Bool>
/// Provides a stream of logout events.
///
/// This method returns an AsyncStream that emits events specifically when the user
/// logs out (transitions from authenticated to unauthenticated state). This is useful
/// for components that need to perform cleanup or reset operations when the user logs out,
/// such as clearing cached data, resetting UI state, or clearing local databases.
///
/// The stream only emits when transitioning from having a token to not having a token.
/// It does not emit during initial app startup if the user is already logged out.
///
/// - Returns: An `AsyncStream<Void>` that emits events when the user logs out.
func observeLogoutEvents() -> AsyncStream<Void>
}

View file

@ -0,0 +1,69 @@
#if DEBUG
import Foundation
public enum AuthSessionServiceMother {
// MARK: - Nested types
private actor FakeAuthSessionService: AuthSessionServiceProtocol {
// MARK: - Properties
private var _accessToken: String?
private var _isLoggedIn: Bool
var accessToken: String? {
_accessToken
}
var isLoggedIn: Bool {
_isLoggedIn
}
// MARK: - Lifecycle
init(
accessToken: String? = nil,
isLoggedIn: Bool
) {
_accessToken = accessToken
_isLoggedIn = isLoggedIn
}
// MARK: - Public
func setAccessToken(_ token: String?) async {
_accessToken = token
_isLoggedIn = token != nil
}
nonisolated func observeLoginState() -> AsyncStream<Bool> {
AsyncStream { continuation in
Task {
await continuation.yield(self.isLoggedIn)
continuation.finish()
}
}
}
nonisolated func observeLogoutEvents() -> AsyncStream<Void> {
AsyncStream { continuation in
continuation.finish()
}
}
}
// MARK: - Public
public static func makeAuthSessionService(
loggedIn: Bool = false
) -> any AuthSessionServiceProtocol {
FakeAuthSessionService(
accessToken: loggedIn ? "test-token" : nil,
isLoggedIn: loggedIn
)
}
}
#endif

View file

@ -0,0 +1,106 @@
import AuthSessionServiceInterface
import XCTest
@testable import AuthSessionService
final class AuthSessionServiceTests: XCTestCase {
// MARK: - Properties
private var service: (any AuthSessionServiceProtocol)!
private var fakeKeychainStore: KeychainStoreProtocol!
// MARK: - Lifecycle
override func setUp() async throws {
try await super.setUp()
let store = KeychainStoreMother.makeKeychainStore()
fakeKeychainStore = store
service = AuthSessionService(
keychainStore: store
)
}
override func tearDown() async throws {
service = nil
fakeKeychainStore = nil
try await super.tearDown()
}
// MARK: - Tests
func testAccessTokenSignIn() async {
// Given
let initialState = await service.isLoggedIn
XCTAssertFalse(initialState, "Should start as logged out")
// When
await service.setAccessToken("1b302f5c-157a-4caf-b450-8e1f7cde01ab")
// Then
let finalState = await service.isLoggedIn
XCTAssertTrue(
finalState,
"It should be logged in after setting token"
)
XCTAssertEqual(
fakeKeychainStore.wrappedValue,
"1b302f5c-157a-4caf-b450-8e1f7cde01ab",
"It should store the correct access token"
)
}
func testAccessTokenSignOut() async {
// Given
await service.setAccessToken("1b302f5c-157a-4caf-b450-8e1f7cde01ab")
let loggedInState = await service.isLoggedIn
XCTAssertTrue(
loggedInState,
"It should be logged in initially"
)
// When
await service.setAccessToken(nil)
// Then
let loggedOutState = await service.isLoggedIn
XCTAssertFalse(
loggedOutState,
"it should be logged out after clearing token"
)
XCTAssertNil(
fakeKeychainStore.wrappedValue,
"It should clear out the access token"
)
}
func testObserveLoginStateYieldsCurrentState() async {
// Given
let initialState = await service.isLoggedIn
XCTAssertFalse(
initialState,
"it should start logged out"
)
// When
let stream = service.observeLoginState()
// Then
var iterator = stream.makeAsyncIterator()
let firstValue = await iterator.next()
XCTAssertEqual(
firstValue,
false,
"It should yielded the first value correctly"
)
}
}

9
Packages/DesignSystem/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

Some files were not shown because too many files have changed in this diff Show more