From 72077c78b2be5557dfa6dfe1a952d1ed7035cff4 Mon Sep 17 00:00:00 2001 From: JAEYOUNGYUN Date: Mon, 13 Jun 2022 15:15:16 +0900 Subject: [PATCH] Create TCA example and refactor core codes for TCA structure (#1) Co-authored-by: Yongwook Choi --- Example/Wrp.xcodeproj/project.pbxproj | 36 +++ Example/Wrp/AsyncSequence+Publisher.swift | 28 ++ Example/Wrp/WrpApp.swift | 289 +----------------- Example/Wrp/WrpExampleServiceProvider.swift | 50 +++ Example/Wrp/WrpSample/WrpSampleAction.swift | 31 ++ .../Wrp/WrpSample/WrpSampleEnvironment.swift | 13 + Example/Wrp/WrpSample/WrpSampleReducer.swift | 139 +++++++++ Example/Wrp/WrpSample/WrpSampleState.swift | 30 ++ .../Wrp/WrpSample/WrpSampleSwiftUIView.swift | 122 ++++++++ Example/Wrp/WrpSample/WrpSampleTCAView.swift | 126 ++++++++ Sources/Wrp/Client/Client.swift | 22 +- Sources/Wrp/Client/Guest.swift | 76 +++-- Sources/Wrp/Core/Glue.swift | 8 +- Sources/Wrp/Core/Socket.swift | 4 +- Sources/Wrp/Server/Server.swift | 23 +- Sources/Wrp/View/WrpView.swift | 21 +- 16 files changed, 674 insertions(+), 344 deletions(-) create mode 100644 Example/Wrp/AsyncSequence+Publisher.swift create mode 100644 Example/Wrp/WrpSample/WrpSampleAction.swift create mode 100644 Example/Wrp/WrpSample/WrpSampleEnvironment.swift create mode 100644 Example/Wrp/WrpSample/WrpSampleReducer.swift create mode 100644 Example/Wrp/WrpSample/WrpSampleState.swift create mode 100644 Example/Wrp/WrpSample/WrpSampleSwiftUIView.swift create mode 100644 Example/Wrp/WrpSample/WrpSampleTCAView.swift diff --git a/Example/Wrp.xcodeproj/project.pbxproj b/Example/Wrp.xcodeproj/project.pbxproj index 3416bd9..4454897 100644 --- a/Example/Wrp.xcodeproj/project.pbxproj +++ b/Example/Wrp.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 571B0BE4283B55A400F57F78 /* WrpExampleServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571B0BE3283B55A400F57F78 /* WrpExampleServiceProvider.swift */; }; 571B0BEA283B63C100F57F78 /* pbkit.wrp.example.WrpExampleService.wrp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571B0BE9283B63C100F57F78 /* pbkit.wrp.example.WrpExampleService.wrp.swift */; }; 57230F5D2844CBEE003237E5 /* wrp-example.proto in Sources */ = {isa = PBXBuildFile; fileRef = 57230F5C2844CBEE003237E5 /* wrp-example.proto */; }; + 57288C402856FA27008CEF23 /* WrpSampleSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57288C3F2856FA27008CEF23 /* WrpSampleSwiftUIView.swift */; }; 5740EE19283F4B7E00DF378A /* Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5740EE18283F4B7E00DF378A /* Style.swift */; }; 5740EE1C2840CC1C00DF378A /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 5740EE1B2840CC1C00DF378A /* ComposableArchitecture */; }; 57D7E61528375E8F00C5F532 /* WrpApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D7E61428375E8F00C5F532 /* WrpApp.swift */; }; @@ -22,6 +23,12 @@ 57D7E61C28375E9100C5F532 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D7E61B28375E9100C5F532 /* Preview Assets.xcassets */; }; 57D7E62C283767E400C5F532 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = 57D7E62B283767E400C5F532 /* SwiftProtobuf */; }; 57D7E62F283767FD00C5F532 /* GRPC in Frameworks */ = {isa = PBXBuildFile; productRef = 57D7E62E283767FD00C5F532 /* GRPC */; }; + C9F4531328459498009A26B2 /* WrpSampleAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F4530E28459498009A26B2 /* WrpSampleAction.swift */; }; + C9F4531428459498009A26B2 /* WrpSampleTCAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F4530F28459498009A26B2 /* WrpSampleTCAView.swift */; }; + C9F4531528459498009A26B2 /* WrpSampleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F4531028459498009A26B2 /* WrpSampleEnvironment.swift */; }; + C9F4531628459498009A26B2 /* WrpSampleReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F4531128459498009A26B2 /* WrpSampleReducer.swift */; }; + C9F4531728459498009A26B2 /* WrpSampleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F4531228459498009A26B2 /* WrpSampleState.swift */; }; + C9F4531A2852FAB5009A26B2 /* AsyncSequence+Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F453192852FAB5009A26B2 /* AsyncSequence+Publisher.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -32,11 +39,18 @@ 571B0BE3283B55A400F57F78 /* WrpExampleServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrpExampleServiceProvider.swift; sourceTree = ""; }; 571B0BE9283B63C100F57F78 /* pbkit.wrp.example.WrpExampleService.wrp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = pbkit.wrp.example.WrpExampleService.wrp.swift; sourceTree = ""; }; 57230F5C2844CBEE003237E5 /* wrp-example.proto */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.protobuf; path = "wrp-example.proto"; sourceTree = ""; }; + 57288C3F2856FA27008CEF23 /* WrpSampleSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrpSampleSwiftUIView.swift; sourceTree = ""; }; 5740EE18283F4B7E00DF378A /* Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Style.swift; sourceTree = ""; }; 57D7E61128375E8F00C5F532 /* WrpExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WrpExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 57D7E61428375E8F00C5F532 /* WrpApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrpApp.swift; sourceTree = ""; }; 57D7E61828375E9100C5F532 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 57D7E61B28375E9100C5F532 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + C9F4530E28459498009A26B2 /* WrpSampleAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrpSampleAction.swift; sourceTree = ""; }; + C9F4530F28459498009A26B2 /* WrpSampleTCAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrpSampleTCAView.swift; sourceTree = ""; }; + C9F4531028459498009A26B2 /* WrpSampleEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrpSampleEnvironment.swift; sourceTree = ""; }; + C9F4531128459498009A26B2 /* WrpSampleReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrpSampleReducer.swift; sourceTree = ""; }; + C9F4531228459498009A26B2 /* WrpSampleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrpSampleState.swift; sourceTree = ""; }; + C9F453192852FAB5009A26B2 /* AsyncSequence+Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncSequence+Publisher.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -80,6 +94,8 @@ 57D7E61828375E9100C5F532 /* Assets.xcassets */, 57D7E61A28375E9100C5F532 /* Preview Content */, 5740EE18283F4B7E00DF378A /* Style.swift */, + C9F453192852FAB5009A26B2 /* AsyncSequence+Publisher.swift */, + C9F45318284594A2009A26B2 /* WrpSample */, ); path = Wrp; sourceTree = ""; @@ -112,6 +128,19 @@ path = Messages; sourceTree = ""; }; + C9F45318284594A2009A26B2 /* WrpSample */ = { + isa = PBXGroup; + children = ( + C9F4530E28459498009A26B2 /* WrpSampleAction.swift */, + C9F4530F28459498009A26B2 /* WrpSampleTCAView.swift */, + C9F4531028459498009A26B2 /* WrpSampleEnvironment.swift */, + C9F4531128459498009A26B2 /* WrpSampleReducer.swift */, + C9F4531228459498009A26B2 /* WrpSampleState.swift */, + 57288C3F2856FA27008CEF23 /* WrpSampleSwiftUIView.swift */, + ); + path = WrpSample; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -194,12 +223,19 @@ buildActionMask = 2147483647; files = ( 571B0BDB2837E3E900F57F78 /* pbkit.wrp.example.GetTextValueResponse.pb.swift in Sources */, + C9F4531728459498009A26B2 /* WrpSampleState.swift in Sources */, + C9F4531328459498009A26B2 /* WrpSampleAction.swift in Sources */, 57D7E61528375E8F00C5F532 /* WrpApp.swift in Sources */, 571B0BDD2837E3E900F57F78 /* pbkit.wrp.example.GetSliderValueRequest.pb.swift in Sources */, 571B0BE4283B55A400F57F78 /* WrpExampleServiceProvider.swift in Sources */, 571B0BDE2837E3E900F57F78 /* pbkit.wrp.example.GetSliderValueResponse.pb.swift in Sources */, + 57288C402856FA27008CEF23 /* WrpSampleSwiftUIView.swift in Sources */, 57230F5D2844CBEE003237E5 /* wrp-example.proto in Sources */, + C9F4531428459498009A26B2 /* WrpSampleTCAView.swift in Sources */, + C9F4531A2852FAB5009A26B2 /* AsyncSequence+Publisher.swift in Sources */, 571B0BDC2837E3E900F57F78 /* pbkit.wrp.example.GetTextValueRequest.pb.swift in Sources */, + C9F4531528459498009A26B2 /* WrpSampleEnvironment.swift in Sources */, + C9F4531628459498009A26B2 /* WrpSampleReducer.swift in Sources */, 571B0BEA283B63C100F57F78 /* pbkit.wrp.example.WrpExampleService.wrp.swift in Sources */, 5740EE19283F4B7E00DF378A /* Style.swift in Sources */, ); diff --git a/Example/Wrp/AsyncSequence+Publisher.swift b/Example/Wrp/AsyncSequence+Publisher.swift new file mode 100644 index 0000000..54262f2 --- /dev/null +++ b/Example/Wrp/AsyncSequence+Publisher.swift @@ -0,0 +1,28 @@ +// +// AsyncSequence+publisher.swift +// WrpExample +// +// Created by Jaeyoung Yoon on 2022/06/10. +// + +import Combine + +extension AsyncSequence { + func toPublisher() -> AnyPublisher { + let subject = PassthroughSubject() + + Task { + do { + for try await element in self { + subject.send(element) + } + + subject.send(completion: .finished) + } catch { + subject.send(completion: .failure(error)) + } + } + + return subject.eraseToAnyPublisher() + } +} diff --git a/Example/Wrp/WrpApp.swift b/Example/Wrp/WrpApp.swift index 3c2e8e6..13dc07e 100644 --- a/Example/Wrp/WrpApp.swift +++ b/Example/Wrp/WrpApp.swift @@ -6,290 +6,11 @@ import Wrp struct WrpApp: App { var body: some Scene { WindowGroup { - TabView { - WrpAppView(url: "https://wrp-example.deno.dev/wrp-example") - .tabItem { - Text("Bidirectional") - }.background(Color.gray.opacity(0.1).ignoresSafeArea()) - WrpServerAppView(url: "https://wrp-example.deno.dev/wrp-example-guest") - .tabItem { - Text("Server") - }.background(Color.gray.opacity(0.1).ignoresSafeArea()) - WrpClientAppView(url: "https://wrp-example.deno.dev/wrp-example-host") - .tabItem { - Text("Client") - }.background(Color.gray.opacity(0.1).ignoresSafeArea()) - } + WrpSampleView(store: .init( + initialState: .init(), + reducer: .init(), + environment: .init(serviceProvider: .init()) + )) } } } - -struct WrpAppView: View { - let url: String - @State var sliderValueStream: DeferStream = .init() - let glue: WrpGlue = .init() - @State var initNumber = 0 - - @State var textValue = "" - @State var sliderValue = 0.0 - - @State var responseTextValue = "" - @State var responseSliderValue = 0 - - @State var wrpExampleClient: Pbkit_Wrp_Example_WrpExampleServiceWrpClient! - - init(url: String) { - self.url = url - } - - var body: some View { - VStack(alignment: .center, spacing: 0) { - Text("Bidirectional (both server/client)").fontWeight(.semibold) - VStack(alignment: .center, spacing: 0) { - Text("Initialize \(initNumber) times") - VStack { - Divider() - Text("Server Inputs").fontWeight(.semibold) - HStack { - Text("Text") - TextField("TextValue", text: $textValue) - } - HStack { - Text("Slider") - Slider(value: $sliderValue, in: 0 ... 100) - } - Divider() - }.padding(.top) - VStack { - Text("Server Response").fontWeight(.semibold) - VStack(alignment: .leading) { - Text("TextValue: \(responseTextValue)") - HStack { - Spacer() - } - Text("SliderValue: \(responseSliderValue)") - } - HStack { - Button("GetTextValue", action: { - if let response = try? wrpExampleClient.getTextValue(.init()) { - Task { - for try await res in response.response { - responseTextValue = res.text - } - } - } - }).buttonStyle( - WrpButtonStyle(color: Color.blue.opacity(0.7)) - ) - Button("GetSliderValue", action: { - if let response = try? wrpExampleClient.getSliderValue(.init()) { - Task { - for try await res in response.response { - responseSliderValue = Int(res.value) - } - } - } - }).buttonStyle( - WrpButtonStyle(color: Color.orange.opacity(0.7)) - ) - } - }.padding([.top]) - }.padding() - Divider() - WrpView( - urlString: self.url, - glue: glue - ).onAppear { - initNumber += 1 - }.onChange(of: initNumber) { _ in - Task { - await withTaskGroup(of: Void.self) { taskGroup in - taskGroup.addTask { - print("Client") - var logger = Logger(label: "io.wrp.client") - logger.logLevel = .debug - let client = WrpClient.create(glue: glue, logger: logger) - self.wrpExampleClient = Pbkit_Wrp_Example_WrpExampleServiceWrpClient(client: client) - try? await client.start() - } - taskGroup.addTask { - print("Server") - let provider = WrpExampleServiceProvider(textValue: $textValue, sliderValueStream: sliderValueStream.stream) - var logger = Logger(label: "io.wrp.server") - logger.logLevel = .debug - let server = WrpServer.create(glue: glue, serviceProviders: [provider], logger: logger) - do { - try await server.start() - initNumber += 1 - } catch { - print("WrpView(Error): \(error)") - } - } - } - } - }.onChange(of: sliderValue) { value in - sliderValueStream.continuation?.yield(value) - }.onChange(of: initNumber) { _ in - sliderValueStream = .init() - } - } - } -} - -#if DEBUG -struct WrpAppViewPreview: PreviewProvider { - static var previews: some View { - Group { - WrpAppView(url: "http://localhost:8000/wrp-example") - } - } -} -#endif - -struct WrpClientAppView: View { - let url: String - let glue: WrpGlue = .init() - @State var initNumber = 0 - - @State var responseTextValue = "" - @State var responseSliderValue = 0 - - @State var wrpExampleClient: Pbkit_Wrp_Example_WrpExampleServiceWrpClient! - - init(url: String) { - self.url = url - } - - var body: some View { - VStack(alignment: .center, spacing: 0) { - Text("Client (<-> WebView Server)").fontWeight(.semibold) - VStack(alignment: .center, spacing: 0) { - Text("Initialize \(initNumber) times") - VStack { - Divider() - Text("Server Response").fontWeight(.semibold) - VStack(alignment: .leading) { - Text("TextValue: \(responseTextValue)") - HStack { - Spacer() - } - Text("SliderValue: \(responseSliderValue)") - } - HStack { - Button("GetTextValue", action: { - if let response = try? wrpExampleClient.getTextValue(.init()) { - Task { - for try await res in response.response { - responseTextValue = res.text - } - } - } - }).buttonStyle( - WrpButtonStyle(color: Color.blue.opacity(0.7)) - ) - Button("GetSliderValue", action: { - if let response = try? wrpExampleClient.getSliderValue(.init()) { - Task { - for try await res in response.response { - responseSliderValue = Int(res.value) - } - } - } - }).buttonStyle( - WrpButtonStyle(color: Color.orange.opacity(0.7)) - ) - } - }.padding([.top]) - }.padding() - Divider() - WrpView( - urlString: self.url, - glue: glue - ).onAppear { - initNumber += 1 - }.onChange(of: initNumber) { _ in - Task { - var logger = Logger(label: "io.wrp") - logger.logLevel = .debug - let client = WrpClient.create(glue: glue, logger: logger) - self.wrpExampleClient = Pbkit_Wrp_Example_WrpExampleServiceWrpClient(client: client) - try? await client.start() - } - } - } - } -} - -#if DEBUG -struct WrpClientAppViewPreview: PreviewProvider { - static var previews: some View { - WrpClientAppView(url: "http://localhost:8000/wrp-example-host") - } -} -#endif - -struct WrpServerAppView: View { - let url: String - @State var sliderValueStream: DeferStream = .init() - let glue: WrpGlue = .init() - @State var initNumber = 0 - @State var textValue = "" - @State var sliderValue = 0.0 - - init(url: String) { - self.url = url - } - - var body: some View { - VStack(alignment: .center, spacing: 0) { - Text("Server (<-> WebView Client)").fontWeight(.semibold) - VStack(alignment: .center, spacing: 0) { - Text("Initialize \(initNumber) times") - VStack { - Divider() - Text("Server Inputs").fontWeight(.semibold) - HStack { - Text("Text") - TextField("TextValue", text: $textValue) - } - HStack { - Text("Slider") - Slider(value: $sliderValue, in: 0 ... 100) - } - }.padding(.top) - }.padding() - Divider() - WrpView( - urlString: self.url, - glue: glue - ).onAppear { - initNumber += 1 - }.onChange(of: initNumber) { _ in - Task { - let provider = WrpExampleServiceProvider(textValue: $textValue, sliderValueStream: sliderValueStream.stream) - var logger = Logger(label: "io.wrp.server") - logger.logLevel = .debug - let server = WrpServer.create(glue: glue, serviceProviders: [provider], logger: logger) - do { - try await server.start() - initNumber += 1 - } catch { - print("WrpView(Error): \(error)") - } - } - }.onChange(of: sliderValue) { value in - sliderValueStream.continuation?.yield(value) - }.onChange(of: initNumber) { _ in - sliderValueStream = .init() - } - } - } -} - -#if DEBUG -struct WrpServerAppViewPreview: PreviewProvider { - static var previews: some View { - WrpServerAppView(url: "http://localhost:8000/wrp-example-guest") - } -} -#endif diff --git a/Example/Wrp/WrpExampleServiceProvider.swift b/Example/Wrp/WrpExampleServiceProvider.swift index 02fca60..f257f51 100644 --- a/Example/Wrp/WrpExampleServiceProvider.swift +++ b/Example/Wrp/WrpExampleServiceProvider.swift @@ -36,3 +36,53 @@ class WrpExampleServiceProvider: Pbkit_Wrp_Example_WrpExampleServiceWrpProvider } } } + +class WrpExampleServiceProviderForTCA: Pbkit_Wrp_Example_WrpExampleServiceWrpProvider { + var text: String? + + private var _sliderValue: Double? + private let sliderValueStream: DeferStream = .init() + private var sliderValueSequence: SharedAsyncSequence> + + init() { + self.sliderValueSequence = self.sliderValueStream.stream.shared() + } + + var sliderValue: Double? { + get { self._sliderValue } + set { + self._sliderValue = newValue + if let value = newValue { + self.sliderValueStream.yield(value) + } + } + } + + func getTextValue( + request: AsyncStream, + context: WrpRequestContext + ) async { + context.sendHeader([:]) + if let text = text { + context.sendMessage(.with { + $0.text = text + }) + } + context.sendTrailer([:]) + } + + func getSliderValue( + request: AsyncStream, + context: WrpRequestContext + ) async { + context.sendHeader([:]) + do { + for try await sliderValue in self.sliderValueSequence { + context.sendMessage(.with { + $0.value = Int32(sliderValue) + }) + } + } catch {} + context.sendTrailer([:]) + } +} diff --git a/Example/Wrp/WrpSample/WrpSampleAction.swift b/Example/Wrp/WrpSample/WrpSampleAction.swift new file mode 100644 index 0000000..05bae6c --- /dev/null +++ b/Example/Wrp/WrpSample/WrpSampleAction.swift @@ -0,0 +1,31 @@ +// +// WrpSampleAction.swift +// WrpExample +// +// Created by Jaeyoung Yoon on 2022/05/31. +// + +import Wrp + +enum WrpSampleAction: Equatable { + case onAppear + case sliderValueChanged(Double) + case textValueChanged(String) + case glueReconnected + + case getTextValue + case getTextValueResult(Result) + case getSliderValue + case getSliderValueResult(Result) + + case startServer + case startServerResult(Result) + case startClient + case startClientResult(Result) +} + +extension Pbkit_Wrp_Example_WrpExampleServiceWrpClient: Equatable { + public static func == (lhs: Pbkit_Wrp_Example_WrpExampleServiceWrpClient, rhs: Pbkit_Wrp_Example_WrpExampleServiceWrpClient) -> Bool { + return false + } +} diff --git a/Example/Wrp/WrpSample/WrpSampleEnvironment.swift b/Example/Wrp/WrpSample/WrpSampleEnvironment.swift new file mode 100644 index 0000000..9982b45 --- /dev/null +++ b/Example/Wrp/WrpSample/WrpSampleEnvironment.swift @@ -0,0 +1,13 @@ +// +// WrpSampleEnvironment.swift +// WrpExample +// +// Created by Jaeyoung Yoon on 2022/05/31. +// + +import Combine +import Wrp + +struct WrpSampleEnvironment { + var serviceProvider: WrpExampleServiceProviderForTCA +} diff --git a/Example/Wrp/WrpSample/WrpSampleReducer.swift b/Example/Wrp/WrpSample/WrpSampleReducer.swift new file mode 100644 index 0000000..2b04498 --- /dev/null +++ b/Example/Wrp/WrpSample/WrpSampleReducer.swift @@ -0,0 +1,139 @@ +// +// WrpSampleReducer.swift +// WrpExample +// +// Created by Jaeyoung Yoon on 2022/05/31. +// + +import Combine +import ComposableArchitecture +import Logging +import Wrp + +typealias WrpSampleReducer = Reducer< + WrpSampleState, + WrpSampleAction, + WrpSampleEnvironment +> + +extension WrpSampleReducer { + init() { + self = Self + .combine( + .init { state, action, environment in + switch action { + case .onAppear: + return .merge( + .init(value: .startServer), + .init(value: .startClient) + ) + + case .startServer: + var logger = Logger(label: "io.wrp.server") + logger.logLevel = .debug + let server = WrpServer.create( + glue: state.glue, + serviceProviders: [environment.serviceProvider], + logger: logger + ) + + return server.start() + .toPublisher() + .receive(on: DispatchQueue.main) + .replaceError(with: "") + .catchToEffect() + .map(WrpSampleAction.startServerResult) + + case .startServerResult(.success): + return .none + + case .startServerResult: + return .none + + case .startClient: + var logger = Logger(label: "io.wrp.client") + logger.logLevel = .debug + let client = WrpClient.create(glue: state.glue, logger: logger) + state.client = client + + return client.start() + .toPublisher() + .receive(on: DispatchQueue.main) + .replaceError(with: "") + .catchToEffect() + .map(WrpSampleAction.startClientResult) + + case .startClientResult(.success): + return .none + + case .startClientResult: + return .none + + case .sliderValueChanged(let value): + state.sliderValue = value + environment.serviceProvider.sliderValue = value + return .none + + case .getSliderValue: + guard + let client = state.client, + let response = try? Pbkit_Wrp_Example_WrpExampleServiceWrpClient(client: client) + .getSliderValue(.init()) + .response + else { + return .none + } + + return response + .toPublisher() + .map { Double($0.value) } + .replaceError(with: 0.0) + .catchToEffect() + .map(WrpSampleAction.getSliderValueResult) + + case .getSliderValueResult(.success(let value)): + state.responseSliderValue = Int(value) + return .none + + case .getSliderValueResult: + return .none + + case .textValueChanged(let value): + state.textValue = value + environment.serviceProvider.text = value + return .none + + case .getTextValue: + guard + let client = state.client, + let response = try? Pbkit_Wrp_Example_WrpExampleServiceWrpClient(client: client) + .getTextValue(.init()) + .response + else { + return .none + } + + return response + .toPublisher() + .map { $0.text } + .replaceError(with: "") + .catchToEffect() + .map(WrpSampleAction.getTextValueResult) + + case .getTextValueResult(.success(let value)): + state.responseTextValue = value + return .none + + case .getTextValueResult: + return .none + + case .glueReconnected: + return .merge( + .init(value: .startClient), + .init(value: .startServer) + ) + } + } + ) + } +} diff --git a/Example/Wrp/WrpSample/WrpSampleState.swift b/Example/Wrp/WrpSample/WrpSampleState.swift new file mode 100644 index 0000000..a10d567 --- /dev/null +++ b/Example/Wrp/WrpSample/WrpSampleState.swift @@ -0,0 +1,30 @@ +// +// WrpSampleState.swift +// WrpExample +// +// Created by Jaeyoung Yoon on 2022/05/31. +// + +import ComposableArchitecture +import Wrp + +struct WrpSampleState: Equatable { + var sliderValue: Double = 0.0 + var textValue: String = "" + var responseTextValue: String = "" + var responseSliderValue: Int = 0 + var glue: WrpGlue = .init() + var client: WrpClient? +} + +extension WrpGlue: Equatable { + public static func == (lhs: WrpGlue, rhs: WrpGlue) -> Bool { + return false + } +} + +extension WrpClient: Equatable { + public static func == (lhs: WrpClient, rhs: WrpClient) -> Bool { + return false + } +} diff --git a/Example/Wrp/WrpSample/WrpSampleSwiftUIView.swift b/Example/Wrp/WrpSample/WrpSampleSwiftUIView.swift new file mode 100644 index 0000000..8705009 --- /dev/null +++ b/Example/Wrp/WrpSample/WrpSampleSwiftUIView.swift @@ -0,0 +1,122 @@ +import Logging +import SwiftUI +import Wrp + +struct WrpAppView: View { + let url: String + @State var sliderValueStream: DeferStream = .init() + let glue: WrpGlue = .init() + @State var initNumber = 0 + + @State var textValue = "" + @State var sliderValue = 0.0 + + @State var responseTextValue = "" + @State var responseSliderValue = 0 + + @State var wrpExampleClient: Pbkit_Wrp_Example_WrpExampleServiceWrpClient! + + init(url: String) { + self.url = url + } + + var body: some View { + VStack(alignment: .center, spacing: 0) { + Text("Bidirectional (both server/client)").fontWeight(.semibold) + VStack(alignment: .center, spacing: 0) { + Text("Initialize \(initNumber) times") + VStack { + Divider() + Text("Server Inputs").fontWeight(.semibold) + HStack { + Text("Text") + TextField("TextValue", text: $textValue) + } + HStack { + Text("Slider") + Slider(value: $sliderValue, in: 0 ... 100) + } + Divider() + }.padding(.top) + VStack { + Text("Server Response").fontWeight(.semibold) + VStack(alignment: .leading) { + Text("TextValue: \(responseTextValue)") + HStack { + Spacer() + } + Text("SliderValue: \(responseSliderValue)") + } + HStack { + Button("GetTextValue", action: { + if let response = try? wrpExampleClient.getTextValue(.init()) { + Task { + for try await res in response.response { + responseTextValue = res.text + } + } + } + }).buttonStyle( + WrpButtonStyle(color: Color.blue.opacity(0.7)) + ) + Button("GetSliderValue", action: { + if let response = try? wrpExampleClient.getSliderValue(.init()) { + Task { + for try await res in response.response { + responseSliderValue = Int(res.value) + } + } + } + }).buttonStyle( + WrpButtonStyle(color: Color.orange.opacity(0.7)) + ) + } + }.padding([.top]) + }.padding() + Divider() + WrpView( + urlString: self.url, + glue: glue + ).onAppear { + initNumber += 1 + }.onChange(of: initNumber) { _ in + Task { + await withTaskGroup(of: Void.self) { taskGroup in + taskGroup.addTask { + print("Client") + var logger = Logger(label: "io.wrp.client") + logger.logLevel = .debug + let client = WrpClient.create(glue: glue, logger: logger) + self.wrpExampleClient = Pbkit_Wrp_Example_WrpExampleServiceWrpClient(client: client) + await client.start() + } + taskGroup.addTask { + print("Server") + let provider = WrpExampleServiceProvider(textValue: $textValue, sliderValueStream: sliderValueStream.stream) + var logger = Logger(label: "io.wrp.server") + logger.logLevel = .debug + let server = WrpServer.create(glue: glue, serviceProviders: [provider], logger: logger) + + await server.start() + initNumber += 1 + } + } + } + }.onChange(of: sliderValue) { value in + sliderValueStream.continuation?.yield(value) + }.onChange(of: initNumber) { _ in + sliderValueStream = .init() + } + } + } +} + +#if DEBUG +struct WrpAppViewPreview: PreviewProvider { + static var previews: some View { + Group { + WrpAppView(url: "http://localhost:8000/wrp-example") + } + } +} +#endif diff --git a/Example/Wrp/WrpSample/WrpSampleTCAView.swift b/Example/Wrp/WrpSample/WrpSampleTCAView.swift new file mode 100644 index 0000000..085d573 --- /dev/null +++ b/Example/Wrp/WrpSample/WrpSampleTCAView.swift @@ -0,0 +1,126 @@ +// +// WrpSampleView.swift +// WrpExample +// +// Created by Jaeyoung Yoon on 2022/05/31. +// + +import ComposableArchitecture +import Logging +import SwiftUI +import Wrp + +// MARK: View + +struct WrpSampleView: View { + @ObservedObject + private var viewStore: WrpSampleViewStore + private let store: WrpSampleStore + + let url: String = "https://pbkit.dev/wrp-example" + @State var wrpExampleClient: Pbkit_Wrp_Example_WrpExampleServiceWrpClient! + + init(store: WrpSampleStore) { + self.viewStore = ViewStore(store) + self.store = store + } + + var body: some View { + WithViewStore(store) { viewStore in + VStack(alignment: .center, spacing: 0) { + Text("Bidirectional (both server/client)").fontWeight(.semibold) + + VStack(alignment: .center, spacing: 0) { + VStack { + Divider() + Text("Server Inputs").fontWeight(.semibold) + HStack { + Text("Text") + TextField( + "TextValue", + text: viewStore.binding( + get: \.textValue, + send: WrpSampleAction.textValueChanged + ) + ) + } + HStack { + Text("Slider") + Slider( + value: viewStore.binding( + get: \.sliderValue, + send: WrpSampleAction.sliderValueChanged + ), + in: 0 ... 100 + ) + } + Divider() + } + .padding(.top) + + VStack { + Text("Server Response").fontWeight(.semibold) + VStack(alignment: .leading) { + Text("TextValue: \(viewStore.responseTextValue)") + HStack { + Spacer() + } + Text("SliderValue: \(viewStore.responseSliderValue)") + } + HStack { + Button("GetTextValue", action: { + viewStore.send(.getTextValue) + }).buttonStyle( + WrpButtonStyle(color: Color.blue.opacity(0.7)) + ) + Button("GetSliderValue", action: { + viewStore.send(.getSliderValue) + }).buttonStyle( + WrpButtonStyle(color: Color.orange.opacity(0.7)) + ) + } + }.padding([.top]) + }.padding() + + Divider() + + WrpView( + urlString: self.url, + glue: viewStore.glue, + onGlueReconnect: { + viewStore.send(.glueReconnected) + } + ) + .onAppear { + viewStore.send(.onAppear) + } + } + } + } +} + +// MARK: Store + +typealias WrpSampleStore = Store< + WrpSampleState, + WrpSampleAction +> + +// MARK: ViewStore + +typealias WrpSampleViewStore = ViewStore< + WrpSampleState, + WrpSampleAction +> + +#if DEBUG +struct WrpServerAppViewPreview: PreviewProvider { + static var previews: some View { + WrpSampleView(store: .init( + initialState: .init(), + reducer: .init(), + environment: .init(serviceProvider: .init()) + )) + } +} +#endif diff --git a/Sources/Wrp/Client/Client.swift b/Sources/Wrp/Client/Client.swift index bb7374e..7ef5e9b 100644 --- a/Sources/Wrp/Client/Client.swift +++ b/Sources/Wrp/Client/Client.swift @@ -14,11 +14,23 @@ public final class WrpClient { self.configuration = configuration } - public func start() async throws { - self.configuration.logger.trace("Trying to start guest") - try await self.guest.start() - self.configuration.logger.trace("Guest started. Start listening") - self.guest.listen() + public func start() -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + Task { + self.configuration.logger.trace("Trying to start guest") + do { + try await self.guest.start() + } catch { + continuation.finish(throwing: error) + } + self.configuration.logger.trace("Guest started. Start listening") + // @TODO: Change to other value + continuation.yield("") + await self.guest.listen() + self.configuration.logger.trace("Gracefully finished") + continuation.finish() + } + } } } diff --git a/Sources/Wrp/Client/Guest.swift b/Sources/Wrp/Client/Guest.swift index dd65800..6dda3e8 100644 --- a/Sources/Wrp/Client/Guest.swift +++ b/Sources/Wrp/Client/Guest.swift @@ -20,47 +20,45 @@ public final class WrpGuest { try await self.channel.socket.handshake() } - public func listen() { - Task.init { - for await message in self.channel.listen() { - guard message.message != nil else { continue } - switch message.message { - case .hostInitialize(let message): - self.availableMethods = message.availableMethods.map { identifier in - try! WrpMethodIdentifier(identifier: identifier) - } - availableMethodsDeferStream.continuation.finish() - continue - case .hostError(let message): - self.configuration.onError?(message.message) - continue - case .hostResStart(let message): - if let request = self.requests[message.reqID] { - request.header.continuation.yield(message.header) - request.header.continuation.finish() - } - continue - case .hostResPayload(let message): - if let request = self.requests[message.reqID] { - request.payload.continuation.yield(message.payload) - } - continue - case .hostResFinish(let message): - if let request = self.requests[message.reqID] { - request.trailer.continuation.yield(message.trailer) - if message.trailer["wrp-status"] == "ok" { - request.payload.continuation.finish() - } else { - _ = message.trailer["wrp-message"] ?? "" - // @TODO: Make DeferStream with AsyncThrowingStream - request.payload.continuation.finish() - } + public func listen() async { + for await message in self.channel.listen() { + guard message.message != nil else { continue } + switch message.message { + case .hostInitialize(let message): + self.availableMethods = message.availableMethods.map { identifier in + try! WrpMethodIdentifier(identifier: identifier) + } + self.availableMethodsDeferStream.continuation.finish() + continue + case .hostError(let message): + self.configuration.onError?(message.message) + continue + case .hostResStart(let message): + if let request = self.requests[message.reqID] { + request.header.continuation.yield(message.header) + request.header.continuation.finish() + } + continue + case .hostResPayload(let message): + if let request = self.requests[message.reqID] { + request.payload.continuation.yield(message.payload) + } + continue + case .hostResFinish(let message): + if let request = self.requests[message.reqID] { + request.trailer.continuation.yield(message.trailer) + if message.trailer["wrp-status"] == "ok" { + request.payload.continuation.finish() + } else { + _ = message.trailer["wrp-message"] ?? "" + // @TODO: Make DeferStream with AsyncThrowingStream + request.payload.continuation.finish() } - self.requests.removeValue(forKey: message.reqID) - continue - default: - continue } + self.requests.removeValue(forKey: message.reqID) + continue + default: + continue } } } diff --git a/Sources/Wrp/Core/Glue.swift b/Sources/Wrp/Core/Glue.swift index 5eb9343..849bc9b 100644 --- a/Sources/Wrp/Core/Glue.swift +++ b/Sources/Wrp/Core/Glue.swift @@ -4,6 +4,7 @@ import WebKit public class WrpGlue { public var webView: WKWebView? + private var initialized: Bool = false private var queue: DeferStream = .init() private var sharedSequence: SharedAsyncSequence> private let configuration: Configuration @@ -27,11 +28,16 @@ public class WrpGlue { } } - public func close() { + public func tryReconnect(afterReconnect: (() -> Void)?) { + guard self.initialized == true else { + self.initialized = true + return + } self.queue.continuation.finish() self.configuration.logger.debug("Closed") self.queue = .init() self.sharedSequence = self.queue.stream.shared() + afterReconnect?() } public func registerWebView(_ webView: WKWebView) { diff --git a/Sources/Wrp/Core/Socket.swift b/Sources/Wrp/Core/Socket.swift index d59e897..76297ee 100644 --- a/Sources/Wrp/Core/Socket.swift +++ b/Sources/Wrp/Core/Socket.swift @@ -13,7 +13,7 @@ public class WrpSocket { } public func handshake(interval: UInt32 = 500, limit: Int = 10) async throws { - for count in 0 ..< interval { + for count in 0 ..< limit { guard let webView = self.glue.webView else { self.configuration.logger.debug("handshake: Cannot find WebView") throw SocketError.webViewError("Cannot find Webview") @@ -35,7 +35,7 @@ public class WrpSocket { self.configuration.logger.debug("handshake: Completed") return } catch { - self.configuration.logger.debug("handshake: Retrying \(count)/\(interval)") + self.configuration.logger.debug("handshake: Retrying \(count)/\(limit)") try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000)) } } diff --git a/Sources/Wrp/Server/Server.swift b/Sources/Wrp/Server/Server.swift index f040b47..42cbc81 100644 --- a/Sources/Wrp/Server/Server.swift +++ b/Sources/Wrp/Server/Server.swift @@ -13,12 +13,23 @@ public final class WrpServer { self.configuration = configuration } - public func start() async throws { - self.configuration.logger.trace("Trying to start host") - try await self.host.start() - self.configuration.logger.trace("Host started. Start listening") - await self.listen() - self.configuration.logger.trace("Gracefully finished") + public func start() -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + Task { + self.configuration.logger.trace("Trying to start host") + do { + try await self.host.start() + } catch { + continuation.finish(throwing: error) + } + self.configuration.logger.trace("Host started. Start listening") + // @TODO: Change to other value + continuation.yield("") + await self.listen() + self.configuration.logger.trace("Gracefully finished") + continuation.finish() + } + } } public func listen() async { diff --git a/Sources/Wrp/View/WrpView.swift b/Sources/Wrp/View/WrpView.swift index 135010c..c4acaf7 100644 --- a/Sources/Wrp/View/WrpView.swift +++ b/Sources/Wrp/View/WrpView.swift @@ -2,18 +2,21 @@ import SwiftUI import WebKit public struct WrpView: UIViewControllerRepresentable { - let urlString: String - let configuration: WKWebViewConfiguration - let glue: WrpGlue + private let urlString: String + private let configuration: WKWebViewConfiguration + private let glue: WrpGlue + private let onGlueReconnect: (() -> Void)? public init( urlString: String, configuration: WKWebViewConfiguration = .init(), - glue: WrpGlue + glue: WrpGlue, + onGlueReconnect: (() -> Void)? = nil ) { self.urlString = urlString self.configuration = configuration self.glue = glue + self.onGlueReconnect = onGlueReconnect } public func makeUIViewController(context: Context) -> some UIViewController { @@ -21,7 +24,8 @@ public struct WrpView: UIViewControllerRepresentable { urlString: self.urlString, configuration: self.configuration, messageHandler: context.coordinator, - glue: self.glue + glue: self.glue, + onGlueClosed: self.onGlueReconnect ) } @@ -54,6 +58,7 @@ class AppBridgeViewController: UIViewController { // @wrp: WrpGlue private let glue: WrpGlue + private let onGlueClosed: (() -> Void)? var webview: WKWebView { return self._webview @@ -75,7 +80,8 @@ class AppBridgeViewController: UIViewController { urlString: String, configuration: WKWebViewConfiguration, messageHandler: WKScriptMessageHandler, - glue: WrpGlue + glue: WrpGlue, + onGlueClosed: (() -> Void)? ) { self.urlString = urlString self.configuration = configuration @@ -83,6 +89,7 @@ class AppBridgeViewController: UIViewController { // @wrp: WrpGlue self.glue = glue + self.onGlueClosed = onGlueClosed super.init(nibName: nil, bundle: nil) } @@ -128,7 +135,7 @@ class AppBridgeViewController: UIViewController { extension AppBridgeViewController: WKUIDelegate, WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish: WKNavigation) { - self.glue.close() + self.glue.tryReconnect(afterReconnect: self.onGlueClosed) } func webView(