Skip to content
2 changes: 1 addition & 1 deletion Macros/AsyncStateMacros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ import SwiftSyntaxMacros
@main
struct AsyncStateMacros: CompilerPlugin {
var providingMacros: [Macro.Type] = [
ModeledViewControllerMacro.self,
ModeledViewControllerMacro.self
]
}
74 changes: 38 additions & 36 deletions Macros/ModeledViewControllerMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,51 +18,49 @@ import SwiftSyntaxMacros
/// into
/// ```swift
/// final class SomeViewController: UIViewController {
/// typealias State = VCState
///
/// typealias ViewModel = VCModel
/// typealias State = VCState
///
/// let viewModel: ViewModel
/// typealias ViewModel = VCModel
///
/// private var stateObservingTask: Task<Void, Never>?
/// private var stateObservingTask: Task<Void, Never>?
///
/// }
/// let viewModel: ViewModel
///
/// extension SomeViewController {
/// /// Start an asynchronous Task which receives state changes and renders them
/// @MainActor
/// private func startObservingState(renderFirst: Bool) {
/// guard stateObservingTask?.isCancelled != false {
/// // already observing
/// return
/// private func startObservingState(renderImmediately: Bool = false) {
/// guard stateObservingTask?.isCancelled != false else {
/// // already observing
/// return
/// }
///
/// let stateStream = viewModel.stateStream.observe()
/// Task { [weak self] in
/// if renderImmediately {
/// await self?.renderCurrentState()
/// }
///
/// var stateIterator = stateStream.makeAsyncIterator()
/// while let newState = await stateIterator.next() {
/// self?.render(newState)
/// }
/// }
/// }
/// }
///
/// /// Retrieve the current state from the ViewModel and render
/// public func renderCurrentState() async {
/// let currentState = await viewModel.currentState()
/// await render(currentState)
/// }
/// let stateStream = viewModel.stateStream.observe()
/// stateObservingTask = Task { [weak self] in
/// if renderImmediately {
/// await self?.renderCurrentState()
/// }
/// var stateIterator = stateStream.makeAsyncIterator()
/// while let newState = await stateIterator.next(), let self {
/// self.render(newState)
/// }
/// }
/// }
///
/// /// Renders the current state and observes
/// /// Stop observing state changes
/// @MainActor
/// private func stopObservingState() {
/// stateObservingTask?.cancel()
/// stateObservingTask = nil
/// }
///
/// /// Retrieve the current controller state
/// func currentState() async -> State {
/// await viewModel.currentState()
/// }
/// }
///
/// extension SomeViewController: ModeledViewController {
/// }
/// ```
public enum ModeledViewControllerMacro {
Expand Down Expand Up @@ -176,15 +174,14 @@ extension ModeledViewControllerMacro: MemberMacro {
return
}

if renderImmediately {
renderCurrentState()
}

let stateStream = viewModel.stateStream.observe()
stateObservingTask = Task { [weak self] in
if renderImmediately {
await self?.renderCurrentState()
}
var stateIterator = stateStream.makeAsyncIterator()
while let newState = await stateIterator.next() {
self?.render(newState)
while let newState = await stateIterator.next(), let self {
self.render(newState)
}
}
}
Expand All @@ -195,6 +192,11 @@ extension ModeledViewControllerMacro: MemberMacro {
stateObservingTask?.cancel()
stateObservingTask = nil
}

/// Retrieve the current controller state
func currentState() async -> State {
await viewModel.currentState()
}
"""
)

Expand Down
26 changes: 15 additions & 11 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ let package = Package(
.iOS(.v13),
.tvOS(.v13),
.watchOS(.v6),
.macCatalyst(.v13),
.macCatalyst(.v13)
],
products: [
.plugin(name: "AsyncStateTemplateInstaller", targets: ["TemplateInstallerPlugin"]),
.library(name: "AsyncState", targets: ["AsyncState"]),
.plugin(name: "AsyncStateTemplates", targets: ["TemplateInstallerPlugin"]),
.library(name: "AsyncState", targets: ["AsyncState"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-syntax", .upToNextMajor(from: "510.0.2")),
.package(url: "https://github.com/apple/swift-docc-plugin", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/apple/swift-syntax", .upToNextMajor(from: "510.0.2"))
],
targets: [
.macro(
Expand All @@ -28,13 +28,17 @@ let package = Package(
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
],
path: "Macros"
),
.plugin(
name: "TemplateInstallerPlugin",
capability: .buildTool(),
capability: .command(
intent: .custom(verb: "async-state-templates", description: "Update XCTemplates for async state"),
permissions: []
)
,
dependencies: [],
path: "Plugins/TemplateInstaller"
),
Expand All @@ -43,14 +47,14 @@ let package = Package(
dependencies: [
"AsyncStateMacros",
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax")
],
path: "Sources"
),
.testTarget(
name: "AsyncStateTests",
dependencies: [
"AsyncState",
"AsyncState"
],
path: "Tests/AsyncStateTests"
),
Expand All @@ -59,9 +63,9 @@ let package = Package(
dependencies: [
"AsyncState",
"AsyncStateMacros",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
],
path: "Tests/AsyncStateMacrosTests"
),
)
]
)
40 changes: 40 additions & 0 deletions Plugins/TemplateInstaller/InstallTemplatesPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation
import PackagePlugin

/// Executes a command to install the lastest AsyncState templates
/// Requires an xcode restart to take effect
@main public struct InstallTemplatesPlugin: CommandPlugin {
public init() { }
public func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws {
let fileManager = FileManager.default
let homeDirectory = FileManager.default.homeDirectoryForCurrentUser
let customTemplateDirectory = homeDirectory.appendingPathComponent("Library/Developer/Xcode/Templates/Async State")
let sourceTemplatesDirectory = context.package.directory.appending("xctemplates")

do {
if fileManager.fileExists(atPath: customTemplateDirectory.path) {
print("Removing existing templates")
let existingTemplates = try fileManager.contentsOfDirectory(atPath: customTemplateDirectory.path)
for template in existingTemplates {
let templatePath = customTemplateDirectory.appendingPathComponent(template).path
try fileManager.removeItem(atPath: templatePath)
}
} else {
print("Creating directory for custom Xcode templates")
try fileManager.createDirectory(at: customTemplateDirectory, withIntermediateDirectories: true, attributes: nil)
}

print("Copying latest templates")
let templates = try fileManager.contentsOfDirectory(atPath: sourceTemplatesDirectory.string)
for template in templates {
let sourcePath = sourceTemplatesDirectory.appending(template)
let destinationPath = customTemplateDirectory.appendingPathComponent(template).path
try fileManager.copyItem(atPath: sourcePath.string, toPath: destinationPath)
print("Created: \(destinationPath)")
}
print("done")
} catch {
print("An error occurred: \(error)")
}
}
}
18 changes: 0 additions & 18 deletions Plugins/TemplateInstaller/TemplateInstallerPlugin.swift

This file was deleted.

7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@ let observerTask = Task { [weak contactRepository] in
```

## Templates
Install templates using the install script (likely `{project-path}/.build/checkouts/async-state/xctemplates/install-xctemplates.sh`)

Run the script and templates will be installed locally. This may require an xcode restart.
Install templates using the `async-state-templates` [CommandPlugin](https://developer.apple.com/documentation/packagedescription/target/plugincapability-swift.enum/command(intent:permissions:))
```bash
swift package --allow-writing-to-directory ~/Library/Developer/Xcode/Templates async-state-templates
```

Once installed, create a ModeledViewController using the latest template.

Expand Down
22 changes: 16 additions & 6 deletions Sources/Concurrency/AsyncBroadcast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,38 @@

import Foundation

public protocol AsyncBroadcast<Element> {
public protocol AsyncBroadcast<Element>: Sendable {
associatedtype Element: Sendable

func observe() -> AsyncStream<Element>
func observe(final: Element?) -> AsyncStream<Element>
}

public protocol AsyncDispatcher: AsyncBroadcast {
func send(_ element: Element)
}

extension AsyncDispatcher {
/// Downcast the dispatcher to hide sendability
/// - Returns: Dispatcher as an erased broadcast
public func erased() -> any AsyncBroadcast<Element> { self }
}

/// Broadcast asynchronously to a group of subscribers
public final class OpenAsyncBroadcast<Element: Sendable>: AsyncBroadcast {
public final class OpenAsyncBroadcast<Element: Sendable>: AsyncDispatcher, AsyncBroadcast, @unchecked Sendable {
private let lock = NSLock()

public private(set) var didFinish: Bool = false
private var continuations = [UUID: (AsyncStream<Element>.Continuation, Element?)]()

public init() {}

public func erased() -> any AsyncBroadcast<Element> {
self
}

public func send(_ element: Element) {
lock.lock()
guard !didFinish else {
lock.unlock()
return
}
let continuations = continuations
lock.unlock()

Expand Down
40 changes: 18 additions & 22 deletions Sources/Effects/EffectHandling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,48 +11,44 @@ import Foundation
public protocol EffectHandling<HandledEffect>: AnyObject {
associatedtype HandledEffect: Effect

/// Handler declaration for a given effect. This is not called from a specific thread and syncronization is an obligation of the implementer
/// Handler declaration for a given effect
/// - Parameter effect: ``Effect``
func handle(_ effect: HandledEffect)
func handle(_ effect: HandledEffect) async

/// Handler declaration for a batch of effects received at the same time.
/// - Parameter effects: ``Array`` of ``Effect``s which are handled by this object
func handle(all effects: [HandledEffect])
func handle(all effects: [HandledEffect]) async
}

public extension EffectHandling {
// Default implementation. Callers may implement this to handle batches of effects together
func handle(all effects: [HandledEffect]) {
// Default implementation. Callers may implement this to handle batches of effects simultaneously
func handle(all effects: [HandledEffect]) async {
for effect in effects {
handle(effect)
await handle(effect)
}
}
}

extension EffectHandling where Self: EffectMapping {
public extension EffectHandling where Self: EffectMapping {
/// Handle some ``Effect`` if it can be mapped to a ``HandledEffect``
/// If the ``Effect`` is not mapped, it will be ignored.
/// - Parameter effect: Any ``Effect`` which may or may not be mapped
func handleIfNeeded<SomeEffect: Effect>(_ effect: SomeEffect) {
assert(
SomeEffect.self != HandledEffect.self,
"\(effect) does not need to be mapped, was this called in error?"
)

Task { [weak self] in
guard let mappedEffect = await self?.map(effect) else { return }

self?.handle(mappedEffect)
func handleIfNeeded(_ effect: any Effect) async {
#if !APPSTORE
if type(of: effect) == HandledEffect.self {
NSLog("Passed effect \(effect) is already \(type(of: HandledEffect.self)) and doesn't need to be mapped. Was this called in error?")
}
#endif
guard let mappedEffect = await map(effect) else { return }

await handle(mappedEffect)
}

/// Handle a batch of ``Effect``s if they can be mapped. ``Effect``s which are not mapped will be ignored.
/// - Parameter effects: ``Array`` of ``Effect``s which may or may not be mapped to a ``HandledEffect``
func handleIfNeeded(_ effects: [any Effect]) {
Task { [weak self] in
guard let mappedEffects = await self?.map(all: effects) else { return }
func handleIfNeeded(_ effects: [any Effect]) async {
let mappedEffects = await map(all: effects)

self?.handle(all: mappedEffects)
}
await handle(all: mappedEffects)
}
}
Loading