diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82be36435..9ad482630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - uses: dtolnay/rust-toolchain@master with: - toolchain: ${{ env.CARGO_MAKE_TOOLCHAIN }} + toolchain: nightly components: rustfmt - name: Cargo fmt diff --git a/.gitignore b/.gitignore index 46c4b2bcc..0edd7162e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ Package.resolved crates/wasm/phoenix_live_view crates/wasm/liveview-native-core-wasm-* crates/wasm/liveview-native-core-wasm-*.tgz + +### When vendoring in as a swift package +.swiftpm diff --git a/Cargo.lock b/Cargo.lock index 66f1ee212..f6317d979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" +[[package]] +name = "android_log-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ecc8056bf6ab9892dcd53216c83d1597487d7dacac16c8df6b877d127df9937" + +[[package]] +name = "android_logger" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f" +dependencies = [ + "android_log-sys", + "env_logger 0.10.2", + "log", + "once_cell", +] + [[package]] name = "anstream" version = "0.6.18" @@ -574,6 +592,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.6.0" @@ -641,6 +672,16 @@ dependencies = [ "regex", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_logger" version = "0.11.6" @@ -1373,24 +1414,29 @@ checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" [[package]] name = "liveview-native-core" -version = "0.4.0-rc-4" +version = "0.4.0" dependencies = [ "Inflector", + "android_logger", + "cookie_store", "cranelift-entity", - "env_logger", + "env_logger 0.11.6", "fixedbitset", "futures", "fxhash", "html5gum", "image", "log", + "oslog", "paste", "petgraph", "phoenix_channels_client", "pretty_assertions", "reqwest", + "reqwest_cookie_store", "serde", "serde_json", + "serde_urlencoded", "smallstr", "smallvec", "tempfile", @@ -1402,7 +1448,7 @@ dependencies = [ [[package]] name = "liveview_native_core_wasm" -version = "0.4.0-rc-4" +version = "0.4.0" dependencies = [ "console_error_panic_hook", "console_log", @@ -1677,6 +1723,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -2056,6 +2113,18 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "reqwest_cookie_store" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b36498c7452f11b1833900f31fbb01fc46be20992a50269c88cf59d79f54e9" +dependencies = [ + "bytes", + "cookie_store", + "reqwest", + "url", +] + [[package]] name = "rgb" version = "0.8.50" @@ -2745,7 +2814,7 @@ dependencies = [ [[package]] name = "uniffi-bindgen" -version = "0.4.0-rc-4" +version = "0.4.0" dependencies = [ "uniffi", ] diff --git a/Cargo.toml b/Cargo.toml index 7c51c7ead..81e121e61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/core", "crates/uniffi-bindgen", "crates/wasm"] [workspace.package] -version = "0.4.0-rc-4" +version = "0.4.0" rust-version = "1.64" authors = [ "Paul Schoenfelder ", @@ -23,7 +23,7 @@ edition = "2021" publish = false [workspace.dependencies] -uniffi = "0.28.0" +uniffi = "0.28.3" [profile.dev] split-debuginfo = "unpacked" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1c8b0fdb5..dccd332cd 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -20,7 +20,14 @@ name = "liveview_native_core" [features] default = ["liveview-channels-tls"] -liveview-channels = ["phoenix_channels_client", "reqwest", "uniffi/tokio"] +liveview-channels = [ + "phoenix_channels_client", + "reqwest", + "uniffi/tokio", + "cookie_store", + "reqwest_cookie_store", + "tokio", +] liveview-channels-tls = [ "liveview-channels", "reqwest/native-tls-vendored", @@ -35,8 +42,15 @@ browser = [ ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +# +[target.'cfg(target_os = "android")'.dependencies] +android_logger = "0.13" + +[target.'cfg(target_vendor = "apple")'.dependencies] +oslog = "0.2.0" [dependencies] +serde_urlencoded = "0.7.1" cranelift-entity = { version = "0.116" } fixedbitset = { version = "0.5" } fxhash = { version = "0.2" } @@ -54,7 +68,13 @@ log = "0.4" reqwest = { version = "0.12.3", default-features = false, optional = true, features = [ "cookies", ] } + +cookie_store = { version = "0.21.1", default-features = false, optional = true } +reqwest_cookie_store = { version = "0.8.0", default-features = false, optional = true } +env_logger = "0.11.1" uniffi = { workspace = true } + +tokio = { version = "1.43", features = ["full"], optional = true } phoenix_channels_client = { git = "https://github.com/liveview-native/phoenix-channels-client.git", branch = "main", optional = true, default-features = false } # This is for wasm support on phoenix-channels-client #phoenix_channels_client = { git = "https://github.com/liveview-native/phoenix-channels-client.git", branch = "simlay/webassembly-support", optional = true, default-features = false } @@ -67,8 +87,6 @@ paste = { version = "1.0" } pretty_assertions = { version = "1.4.0" } text-diff = { version = "0.4.0" } uniffi = { workspace = true, features = ["bindgen-tests", "tokio"] } -tokio = { version = "1.43", features = ["full"] } -env_logger = "0.11.1" # For image generation for tests image = "0.25.1" diff --git a/crates/core/liveview-native-core-jetpack/core/src/test/java/org/phoenixframework/liveview_jetpack/DocumentTest.kt b/crates/core/liveview-native-core-jetpack/core/src/test/java/org/phoenixframework/liveview_jetpack/DocumentTest.kt index 419c03573..cb0ec2c2e 100644 --- a/crates/core/liveview-native-core-jetpack/core/src/test/java/org/phoenixframework/liveview_jetpack/DocumentTest.kt +++ b/crates/core/liveview-native-core-jetpack/core/src/test/java/org/phoenixframework/liveview_jetpack/DocumentTest.kt @@ -7,10 +7,8 @@ import org.junit.Assert.assertEquals import org.junit.Test import org.phoenixframework.liveviewnative.core.ChangeType import org.phoenixframework.liveviewnative.core.ConnectOpts -import org.phoenixframework.liveviewnative.core.ControlFlow import org.phoenixframework.liveviewnative.core.Document import org.phoenixframework.liveviewnative.core.DocumentChangeHandler -import org.phoenixframework.liveviewnative.core.LiveChannelStatus import org.phoenixframework.liveviewnative.core.LiveFile import org.phoenixframework.liveviewnative.core.LiveSocket import org.phoenixframework.liveviewnative.core.NavOptions @@ -62,10 +60,6 @@ class SimpleChangeHandler : DocumentChangeHandler { ) { println("${changeType}") } - - override fun `handleChannelStatus`(`channelStatus`: LiveChannelStatus): ControlFlow { - return ControlFlow.ContinueListening - } } class DocumentTest { diff --git a/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift b/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift index b371faf83..d2129cc61 100644 --- a/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift +++ b/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift @@ -1,3 +1,4 @@ +import Combine import Foundation extension LiveViewNativeCore.ChannelStatus: @unchecked Sendable {} @@ -1884,46 +1885,134 @@ extension DecodingError { } } -final class SimpleHandler: DocumentChangeHandler { - let callback: (NodeRef, NodeData, NodeRef?) -> Void +public struct PatchEvent { + public let node: NodeRef + public let data: NodeData + public let parent: NodeRef? + public let changeType: ChangeType +} - init( - _ callback: @escaping (NodeRef, NodeData, NodeRef?) -> Void +public struct NetworkEvent { + public let event: EventPayload +} + +public struct ChannelStatusEvent { + public let status: LiveChannelStatus +} + +public struct SocketStatusEvent { + public let status: SocketStatus +} + +public struct ViewReloadEvent { + public let document: Document + public let channel: LiveChannel + public let socket: Socket + public let isNewSocket: Bool +} + +public final class SimplePatchHandler: DocumentChangeHandler { + public let patchEventSubject = PassthroughSubject() + + public init() {} + + public func handleDocumentChange( + _ changeType: ChangeType, _ nodeRef: NodeRef, _ nodeData: NodeData, _ parent: NodeRef? ) { - self.callback = callback + let event = PatchEvent( + node: nodeRef, + data: nodeData, + parent: parent, + changeType: changeType + ) + + patchEventSubject.send(event) + } +} + +public final class SimpleEventHandler: NetworkEventHandler { + + public let networkEventSubject = PassthroughSubject() + public let channelStatusSubject = PassthroughSubject() + public let socketStatusSubject = PassthroughSubject() + public let viewReloadSubject = PassthroughSubject() + + public init() { + } + + public func handleEvent(_ event: EventPayload) { + networkEventSubject.send(NetworkEvent(event: event)) + } + + public func handleChannelStatusChange(_ status: LiveChannelStatus) { + channelStatusSubject.send(ChannelStatusEvent(status: status)) + } + + public func handleSocketStatusChange(_ status: SocketStatus) { + socketStatusSubject.send(SocketStatusEvent(status: status)) } - func handleDocumentChange( - _ changeType: ChangeType, _ node: NodeRef, _ data: NodeData, _ parent: NodeRef? + public func handleViewReloaded( + _ newDocument: Document, _ newChannel: LiveChannel, _ currentSocket: Socket, + _ socketIsNew: Bool ) { - switch changeType { - case .add: - self.callback(parent!, data, parent) - case .remove: - self.callback(parent!, data, parent) - case .change: - self.callback(node, data, parent) - case .replace: - self.callback(parent!, data, parent) - } - } - - func handleChannelStatus(_ channelStatus: LiveChannelStatus) -> ControlFlow { - switch channelStatus { - case .joined, - .joining, - .leaving, - .shuttingDown, - .waitingForSocketToConnect, - .waitingToJoin, - .waitingToRejoin: - return .continueListening - case .left, - .shutDown: - return .exitOk + let event = ViewReloadEvent( + document: newDocument, + channel: newChannel, + socket: currentSocket, + isNewSocket: socketIsNew + ) + viewReloadSubject.send(event) + } +} + +public class SimplePersistentStore: SecurePersistentStore { + private let storageDirectory: URL + + public init() { + let fileManager = FileManager.default + let directory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + self.storageDirectory = directory.appendingPathComponent( + "PersistentStore", isDirectory: true) + + try? fileManager.createDirectory( + at: storageDirectory, + withIntermediateDirectories: true) + } + + private func fileURL(for key: String) -> URL { + storageDirectory.appendingPathComponent(key) + } + + public func removeEntry(_ key: String) { + let fileURL = fileURL(for: key) + try? FileManager.default.removeItem(at: fileURL) + } + + public func get(_ key: String) -> Data? { + let fileURL = fileURL(for: key) + guard let data = try? Data(contentsOf: fileURL) else { + return nil } + return data } + public func set(_ key: String, _ value: Data) { + let fileURL = fileURL(for: key) + let data = Data(value) + try? data.write(to: fileURL) + } +} + +public final class SimpleNavHandler: NavEventHandler { + public let navEventSubject = PassthroughSubject() + + public init() {} + + public func handleEvent(_ event: NavEvent) -> HandlerResponse { + navEventSubject.send(event) + return HandlerResponse.default + } } extension Document { @@ -1944,12 +2033,14 @@ extension Document { return try self.mergeFragmentJson(payload) } - public func on(_ event: EventType, _ callback: @escaping (NodeRef, NodeData, NodeRef?) -> Void) - { - - let simple = SimpleHandler(callback) - self.setEventHandler(simple) + public func on( + _ event: EventType, + _ callback: @escaping (NodeRef, NodeData, NodeRef?) -> Void + ) { + //let simple = SimplePatchHandler(callback) + //sself.setEventHandler(simple) } + public func toString() -> String { return self.render() } diff --git a/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift b/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift index aeee5c609..d4fed0e46 100644 --- a/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift +++ b/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift @@ -38,6 +38,70 @@ final class LiveViewNativeCoreSocketTests: XCTestCase { status = socket.status() XCTAssertEqual(status, .shutDown) } + + func testBasicConnection() async throws { + let builder = LiveViewClientBuilder() + let client = try await builder.connect("http://127.0.0.1:4001/hello", ClientConnectOpts()) + let document = try client.document() + + let expected = """ + + + + Hello SwiftUI! + + + """ + + let exp = try Document.parse(expected) + XCTAssertEqual(document.render(), exp.render()) + } + + func testNavigation() async throws { + let builder = LiveViewClientBuilder() + let client = try await builder.connect( + "http://127.0.0.1:4001/nav/first_page", ClientConnectOpts()) + + let initialDoc = try client.document() + let expectedInitial = """ + + + + first_page + + + + NEXT + + + + """ + let expInitial = try Document.parse(expectedInitial) + XCTAssertEqual(initialDoc.render(), expInitial.render()) + + let secondPageId = try await client.navigate( + "http://127.0.0.1:4001/nav/second_page", NavOptions()) + + // document should change. + // TODO: validate doc change is sent in event loop + let secondDoc = try client.document() + let expectedSecond = """ + + + + second_page + + + + NEXT + + + + """ + let expSecond = try Document.parse(expectedSecond) + XCTAssertEqual(secondDoc.render(), expSecond.render()) + + } } // This is a PNG located at crates/core/tests/support/tinycross.png diff --git a/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreTests.swift b/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreTests.swift index 63d3f66bc..5e99a4c39 100644 --- a/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreTests.swift +++ b/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreTests.swift @@ -9,8 +9,8 @@ final class SimpleHandler: DocumentChangeHandler { print("Handler:", changeType, ", node:", nodeRef.ref()) } - func handleChannelStatus(_ channelStatus: LiveChannelStatus) -> ControlFlow { - return .continueListening + func handleNewDocument(_ document: Document) { + } } final class LiveViewNativeTreeTests: XCTestCase { diff --git a/crates/core/src/callbacks.rs b/crates/core/src/callbacks.rs new file mode 100644 index 000000000..0c5c769a5 --- /dev/null +++ b/crates/core/src/callbacks.rs @@ -0,0 +1,189 @@ +use std::sync::Arc; + +#[cfg(feature = "liveview-channels")] +use phoenix_channels_client::{Socket, SocketStatus}; + +use crate::dom::{NodeData, NodeRef}; +#[cfg(feature = "liveview-channels")] +use crate::{dom::ffi::Document, live_socket::LiveChannel}; + +/// Provides secure persistent storage for session data like cookies. +/// Implementations should handle platform-specific storage (e.g. NSUserDefaults on iOS) +/// and ensure data is stored securely as some of it may be session tokens. +#[uniffi::export(callback_interface)] +pub trait SecurePersistentStore: Send + Sync { + /// Removes the entry for the given key + fn remove_entry(&self, key: String); + + /// Gets the value for the given key, or None if not found + fn get(&self, key: String) -> Option>; + + /// Sets the value for the given key + fn set(&self, key: String, value: Vec); +} + +#[uniffi::export(callback_interface)] +pub trait NavEventHandler: Send + Sync { + /// This callback instruments events that occur when your user navigates to a + /// new view. You can add serialized metadata to these events as a byte buffer + /// through the [NavOptions] object. + fn handle_event(&self, event: NavEvent) -> HandlerResponse; +} + +/// Unique id in the history stack +pub type HistoryId = u64; + +/// User emitted response from [NavEventHandler::handle_event]. +/// Determines whether or not the default navigation action is taken. +#[derive(uniffi::Enum, Clone, Debug, PartialEq, Default)] +pub enum HandlerResponse { + #[default] + /// Return this to proceed as normal. + Default, + /// Return this to cancel the navigation before it occurs. + PreventDefault, +} + +#[derive(uniffi::Enum, Clone, Debug, PartialEq)] +pub enum NavEventType { + /// Changing the url of the object on the top of the stack + Patch, + /// Pushing a new event onto the history stack + Push, + /// Replacing the most recent event on the history stack + Replace, + /// Reloading the view in place + Reload, + /// Skipping multiple items on the history stack, leaving them in tact. + Traverse, +} + +#[derive(uniffi::Record, Clone, Debug, PartialEq)] +pub struct NavHistoryEntry { + /// The target url. + pub url: String, + /// Unique id for this piece of nav entry state. + pub id: HistoryId, + /// state passed in by the user, to be passed in to the navigation event callback. + pub state: Option>, +} + +impl NavHistoryEntry { + /// Create a new navigation history entry + pub fn new(url: String, id: HistoryId, state: Option>) -> Self { + Self { url, id, state } + } +} + +/// An event emitted when the user navigates between views. +#[derive(uniffi::Record, Clone, Debug, PartialEq)] +pub struct NavEvent { + /// The type of event being emitted. + pub event: NavEventType, + /// True if from and to point to the same path. + pub same_document: bool, + /// The previous location of the page, if there was one. + pub from: Option, + /// Destination URL. + pub to: NavHistoryEntry, + /// Additional user provided metadata handed to the event handler. + pub info: Option>, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum LiveChannelStatus { + /// [Channel] is waiting for the [Socket](crate::Socket) to + /// [Socket::connect](crate::Socket::connect) or automatically reconnect. + WaitingForSocketToConnect, + /// [Socket::status](crate::Socket::status) is + /// [SocketStatus::Connected](crate::SocketStatus::Connected) and [Channel] is waiting for + /// [Channel::join] to be called. + WaitingToJoin, + /// [Channel::join] was called and awaiting response from server. + Joining, + /// [Channel::join] was called previously, but the [Socket](crate::Socket) was disconnected and + /// reconnected. + WaitingToRejoin, + /// [Channel::join] was called and the server responded that the [Channel::topic] was joined + /// using [Channel::payload]. + Joined, + /// [Channel::leave] was called and awaiting response from server. + Leaving, + /// [Channel::leave] was called and the server responded that the [Channel::topic] was left. + Left, + /// [Channel::shutdown] was called, but the async task hasn't exited yet. + ShuttingDown, + /// The async task has exited. + ShutDown, +} + +#[repr(C)] +#[derive(Copy, Clone, uniffi::Enum)] +pub enum ChangeType { + Change = 0, + Add = 1, + Remove = 2, + Replace = 3, +} + +#[derive(Copy, Clone, uniffi::Enum)] +pub enum EventType { + Changed, // { change: ChangeType }, +} + +#[derive(Clone, uniffi::Enum)] +pub enum ControlFlow { + ExitOk, + ExitErr(String), + ContinueListening, +} + +/// Implements the change handling logic for inbound virtual dom +/// changes. Your logic for handling document patches should go here. +#[uniffi::export(callback_interface)] +pub trait DocumentChangeHandler: Send + Sync { + /// This callback should implement your dom manipulation logic + /// after receiving patches from LVN. + fn handle_document_change( + &self, + change_type: ChangeType, + node_ref: Arc, + node_data: NodeData, + parent: Option>, + ); +} + +/// Implement this if you need to instrument all replies and status +/// changes on the current live channel. +#[cfg(feature = "liveview-channels")] +#[uniffi::export(callback_interface)] +pub trait NetworkEventHandler: Send + Sync { + /// Whenever a server sent event or reply to a user + /// message is receiver the event payload is passed to this + /// callback. by default the client handles diff events and will + /// handle assets_change, live_patch, live_reload, etc, in the future + fn handle_event(&self, event: phoenix_channels_client::EventPayload); + + /// Called when the current LiveChannel status changes. + fn handle_channel_status_change(&self, event: LiveChannelStatus); + + /// Called when the LiveSocket status changes. + fn handle_socket_status_change(&self, event: SocketStatus); + + /// Called when the view is reloaded, provides the new document. + /// This means that the previous livechannel has been dropped and + /// a new livechannel has been established + /// + /// The socket may be the same as the previous view if the navigation + /// API was used within the same livesession, if this is the case + /// `socket_is_new` will be false + /// + /// If the socket was reconnected for any reason `socket_is_new` will be true. + fn handle_view_reloaded( + &self, + new_document: Arc, + new_channel: Arc, + current_socket: Arc, + socket_is_new: bool, + ); +} diff --git a/crates/core/src/client/config.rs b/crates/core/src/client/config.rs new file mode 100644 index 000000000..787a46c58 --- /dev/null +++ b/crates/core/src/client/config.rs @@ -0,0 +1,137 @@ +use std::{collections::HashMap, sync::Arc}; + +use phoenix_channels_client::JSON; + +use crate::{callbacks::*, live_socket::Method}; + +#[derive(uniffi::Enum, Debug, Clone, Default, Copy)] +pub enum LogLevel { + Trace, + Debug, + #[default] + Info, + Warn, + Error, +} + +const SWIFTUI: &str = "swiftui"; +const JETPACK: &str = "jetpack"; + +#[derive(uniffi::Enum, Debug, Clone)] +/// Represents one of our supported platforms. +pub enum Platform { + Swiftui, + Jetpack, + Other(String), +} + +impl std::fmt::Display for Platform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Platform::Swiftui => f.write_str(SWIFTUI), + Platform::Jetpack => f.write_str(JETPACK), + Platform::Other(o) => f.write_str(o), + } + } +} + +impl From for Platform { + fn from(value: String) -> Self { + match value.as_str() { + SWIFTUI => Platform::Swiftui, + JETPACK => Platform::Jetpack, + _ => Platform::Other(value), + } + } +} + +impl Default for Platform { + fn default() -> Self { + // this could be cfg blocks but clippy complains + if cfg!(target_vendor = "apple") { + Platform::Swiftui + } else if cfg!(target_os = "android") { + Platform::Jetpack + } else { + Platform::Other("undefined_format".to_string()) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, uniffi::Record)] +pub struct ClientConnectOpts { + /// Params passed upon joining the live socket + #[uniffi(default = None)] + pub join_params: Option>, + /// Headers passed while fetching the dead render + #[uniffi(default = None)] + pub headers: Option>, + #[uniffi(default = None)] + pub method: Option, + #[uniffi(default = None)] + pub request_body: Option>, +} + +#[derive(Clone)] +pub struct LiveViewClientConfiguration { + /// Instruments all server side events and changes in the current LiveChannel state, including when + /// the channel is swapped out. + pub network_event_handler: Option>, + /// Provides a way to store persistent state between sessions. Used for cookies and potentially persistent settings. + pub persistence_provider: Option>, + /// Instruments the patches provided by `diff` events. + pub patch_handler: Option>, + /// An event handler for application navigation events, this is meant for client developer use + /// If you are looking to expose navigation event handling to the user, see the api endpoints with the + /// `app` prefix. + pub navigation_handler: Option>, + /// Initial log level - defaults to [LogLevel::Info] + pub log_level: LogLevel, + /// Timeout when connecting to a new view. + pub dead_render_timeout: u64, + /// Timeout when sending messages to the server via websocket + pub websocket_timeout: u64, + /// The _format argument passed on connection. + pub format: Platform, +} + +impl Default for LiveViewClientConfiguration { + fn default() -> Self { + const DEAD_RENDER_TIMEOUT_MS: u64 = 30_000; + const WEBSOCKET_TIMEOUT_MS: u64 = 5_000; + + Self { + network_event_handler: None, + persistence_provider: None, + patch_handler: None, + navigation_handler: None, + log_level: LogLevel::Info, + dead_render_timeout: DEAD_RENDER_TIMEOUT_MS, + websocket_timeout: WEBSOCKET_TIMEOUT_MS, + format: Platform::default(), + } + } +} + +impl std::fmt::Debug for LiveViewClientConfiguration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LiveViewClientConfiguration") + .field( + "persistence_provider", + &self.persistence_provider.is_some().then_some("..."), + ) + .field( + "patch_handler", + &self.patch_handler.is_some().then_some("..."), + ) + .field( + "navigation_handler", + &self.navigation_handler.is_some().then_some("..."), + ) + .field("log_level", &self.log_level) + .field("dead_render_timeout", &self.dead_render_timeout) + .field("websocket_timeout", &self.websocket_timeout) + .field("format", &self.format) + .finish() + } +} diff --git a/crates/core/src/client/inner/channel_init.rs b/crates/core/src/client/inner/channel_init.rs new file mode 100644 index 000000000..5177e8e6e --- /dev/null +++ b/crates/core/src/client/inner/channel_init.rs @@ -0,0 +1,119 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; + +use log::{debug, trace}; +use phoenix_channels_client::{Payload, Socket, Topic, JSON}; + +use super::LiveViewClientConfiguration; +use crate::{ + diff::fragment::{Root, RootDiff}, + dom::Document, + error::LiveSocketError, + live_socket::{LiveChannel, SessionData}, +}; + +const LVN_VSN: &str = "2.0.0"; +const LVN_VSN_KEY: &str = "vsn"; + +/// TODO: Post refactor turn this into a private constructor on a LiveChannel +pub async fn join_liveview_channel( + socket: &Mutex>, + session_data: &Mutex, + additional_params: &Option>, + redirect: Option, + ws_timeout: std::time::Duration, +) -> Result, LiveSocketError> { + let sock = socket.try_lock()?.clone(); + sock.connect(ws_timeout).await?; + + let sent_join_payload = session_data + .try_lock()? + .create_join_payload(additional_params, redirect); + let topic = Topic::from_string(format!("lv:{}", session_data.try_lock()?.phx_id)); + let channel = sock.channel(topic, Some(sent_join_payload)).await?; + + let join_payload = channel.join(ws_timeout).await?; + + trace!("Join payload: {join_payload:#?}"); + let document = match join_payload { + Payload::JSONPayload { + json: JSON::Object { ref object }, + } => { + if let Some(rendered) = object.get("rendered") { + let rendered = rendered.to_string(); + let root: RootDiff = serde_json::from_str(rendered.as_str())?; + trace!("root diff: {root:#?}"); + let root: Root = root.try_into()?; + let rendered: String = root.clone().try_into()?; + let mut document = Document::parse(&rendered)?; + document.fragment_template = Some(root); + Some(document) + } else { + None + } + } + _ => None, + } + .ok_or(LiveSocketError::NoDocumentInJoinPayload)?; + + Ok(LiveChannel { + channel, + join_payload, + join_params: additional_params.clone().unwrap_or_default(), + socket: socket.try_lock()?.clone(), + document: document.into(), + timeout: ws_timeout, + } + .into()) +} + +pub async fn join_livereload_channel( + config: &LiveViewClientConfiguration, + socket: &Mutex>, + session_data: &Mutex, + cookies: Option>, +) -> Result, LiveSocketError> { + let ws_timeout = Duration::from_millis(config.websocket_timeout); + + let mut url = session_data.try_lock()?.url.clone(); + + let websocket_scheme = match url.scheme() { + "https" => "wss", + "http" => "ws", + scheme => { + return Err(LiveSocketError::SchemeNotSupported { + scheme: scheme.to_string(), + }) + } + }; + let _ = url.set_scheme(websocket_scheme); + url.set_path("phoenix/live_reload/socket/websocket"); + url.query_pairs_mut().append_pair(LVN_VSN_KEY, LVN_VSN); + + let new_socket = Socket::spawn(url.clone(), cookies).await?; + new_socket.connect(ws_timeout).await?; + + debug!("Joining live reload channel on url {url}"); + let channel = new_socket + .channel(Topic::from_string("phoenix:live_reload".to_string()), None) + .await?; + + debug!("Created channel for live reload socket"); + let join_payload = channel.join(ws_timeout).await?; + let document = Document::empty(); + + Ok(LiveChannel { + channel, + join_params: Default::default(), + join_payload, + // Q: I copy pasted this from the old implementation, + // why use the old socket ? + socket: socket.try_lock()?.clone(), + document: document.into(), + timeout: ws_timeout, + } + .into()) +} diff --git a/crates/core/src/client/inner/cookie_store.rs b/crates/core/src/client/inner/cookie_store.rs new file mode 100644 index 000000000..7bbbb3fdf --- /dev/null +++ b/crates/core/src/client/inner/cookie_store.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use log::{error, warn}; +use reqwest::{cookie::CookieStore, header::HeaderValue, Url}; + +use crate::callbacks::SecurePersistentStore; + +const COOKIE_STORE_KEY: &str = "COOKIE_CACHE"; + +pub struct PersistentCookieStore { + store: Arc, + persistent_store: Option>, +} + +impl PersistentCookieStore { + pub fn new(persistent_store: Option>) -> Self { + let cookie_store = if let Some(store) = &persistent_store { + if let Some(binary_json) = store.get(COOKIE_STORE_KEY.to_owned()) { + match cookie_store::serde::json::load(binary_json.as_slice()) { + Ok(store) => store, + Err(e) => { + error!( + "Failed to load cookie store: {} - defaulting to empty store", + e + ); + reqwest_cookie_store::CookieStore::default() + } + } + } else { + reqwest_cookie_store::CookieStore::default() + } + } else { + warn!("No persistent store configured, no cookies will be loaded from disk."); + reqwest_cookie_store::CookieStore::default() + }; + + let store = Arc::new(reqwest_cookie_store::CookieStoreMutex::new(cookie_store)); + + Self { + store, + persistent_store, + } + } + pub fn get_cookie_list(&self, url: &Url) -> Option> { + self.cookies(url).map(|header| { + header + .to_str() + .unwrap_or_default() + .split("; ") + .map(|s| s.to_string()) + .collect::>() + }) + } + + pub fn save(&self) { + let Some(store) = &self.persistent_store else { + warn!("No persistence provider while attempting to save, Cookies will not persist"); + return; + }; + + let mut buffer = Vec::new(); + let store_guard = self.store.lock().unwrap(); + + if let Err(e) = cookie_store::serde::json::save(&store_guard, &mut buffer) { + warn!("Failed to serialize cookie store: {}", e); + return; + } + + store.set(COOKIE_STORE_KEY.to_owned(), buffer) + } +} + +impl CookieStore for PersistentCookieStore { + fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &Url) { + CookieStore::set_cookies(self.store.as_ref(), cookie_headers, url); + self.save(); + } + + fn cookies(&self, url: &Url) -> Option { + CookieStore::cookies(self.store.as_ref(), url) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, sync::Mutex}; + + use super::*; + + #[derive(Default, Debug)] + struct InMemoryStore(Mutex>>); + + impl SecurePersistentStore for InMemoryStore { + fn remove_entry(&self, key: String) { + self.0.lock().unwrap().remove(&key); + } + + fn get(&self, key: String) -> Option> { + self.0.lock().unwrap().get(&key).cloned() + } + + fn set(&self, key: String, value: Vec) { + self.0.lock().unwrap().insert(key, value); + } + } + + #[test] + fn test_cookie_persistence() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let store = Arc::new(InMemoryStore::default()); + let cookie_store = PersistentCookieStore::new(Some(store.clone())); + + let url = "https://example.com".parse().unwrap(); + + let persistent_cookie = + "session=123; Domain=example.com; Expires=Fri, 31 Dec 9999 23:59:59 GMT"; + + let headers = [HeaderValue::from_static(persistent_cookie)]; + + cookie_store.set_cookies(&mut headers.iter(), &url); + cookie_store.save(); + + let new_store = PersistentCookieStore::new(Some(store)); + assert!(new_store.cookies(&url).is_some()); + } + + #[test] + fn test_no_persistence() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let store = PersistentCookieStore::new(None); + let url = "https://example.com".parse().unwrap(); + let headers = [HeaderValue::from_static("session=123")]; + + store.set_cookies(&mut headers.iter(), &url); + store.save(); + } +} diff --git a/crates/core/src/client/inner/event_loop/mod.rs b/crates/core/src/client/inner/event_loop/mod.rs new file mode 100644 index 000000000..11a193fa7 --- /dev/null +++ b/crates/core/src/client/inner/event_loop/mod.rs @@ -0,0 +1,227 @@ +mod state; + +use std::sync::Arc; + +use futures::{channel::oneshot, pin_mut, select, FutureExt}; +use log::error; +use phoenix_channels_client::{CallError, Event, Payload}; +use state::{EventLoopState, ReplyAction}; +use tokio::sync::mpsc; + +use super::{HistoryId, LiveViewClientState, NavigationSummary, NetworkEventHandler}; +use crate::error::LiveSocketError; + +const MAX_REDIRECTS: u32 = 10; + +pub struct LiveViewClientChannel { + /// Allows sending events back to the main event loop + message_sender: mpsc::UnboundedSender, +} + +impl LiveViewClientChannel { + pub async fn call( + &self, + event_name: String, + payload: Payload, + ) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + + let _ = self.message_sender.send(ClientMessage::Call { + response_tx, + event: Event::from_string(event_name), + payload, + }); + + let resp = response_rx.await.map_err(|e| LiveSocketError::Call { + error: format!("{e}"), + })??; + + Ok(resp) + } + + pub async fn cast(&self, event_name: String, payload: Payload) { + let _ = self.message_sender.send(ClientMessage::Cast { + event: Event::from_string(event_name), + payload, + }); + } +} + +/// Messages to the main background event loop +pub enum ClientMessage { + /// Send a message and wait for the response, + /// If it is an event it will be processed by the loop + Call { + response_tx: oneshot::Sender>, + event: Event, + payload: Payload, + }, + /// Send a message and don't wait for a response + Cast { event: Event, payload: Payload }, + /// Replace the current channel + RefreshView { socket_reconnected: bool }, + /// For internal use, error events are not broadcast + /// from the socket when you attempt to connect to a channel. + /// So we reinject them into the event loop with these messages + HandleSocketReply { + payload: Payload, + tx: oneshot::Sender>, + }, +} + +pub(crate) struct EventLoop { + msg_tx: mpsc::UnboundedSender, + main_background_task: tokio::task::JoinHandle<()>, +} + +impl EventLoop { + pub fn new(client_state: Arc) -> Self { + let (msg_tx, mut msg_rx) = mpsc::unbounded_channel(); + let mut state = EventLoopState::new(client_state); + + state.refresh_view(true); + + let main_background_task = tokio::spawn(async move { + // the main event loop + loop { + let mut view_refresh_needed = false; + let mut socket_reconnected = false; + + { + let client_msg = msg_rx.recv().fuse(); + let (server_event, chan_status, socket_status) = state.event_futures(); + let (server_event, chan_status, socket_status) = ( + server_event.fuse(), + chan_status.fuse(), + socket_status.fuse(), + ); + + pin_mut!(client_msg, server_event, chan_status, socket_status); + + select! { + // local control flow and outbound messages + message = client_msg => { + let Some(msg) = message else { + error!("All client message handlers dropped."); + continue; + }; + let _ = state.handle_client_message(msg, &mut view_refresh_needed, &mut socket_reconnected).await; + } + // networks events from the server + event = server_event => { + let Ok(payload) = event else { + error!("Error retrieving event from main live channel or live_reload channel: {event:?}"); + continue; + }; + + if let Err(e) = state.handle_server_event(&payload, &mut view_refresh_needed, &mut socket_reconnected).await { + error!("Failure while handling server reply: {e:?}"); + } + + state.on_event(payload); + } + // connectivity changes + new_status = chan_status => { + match new_status { + Ok(status) => state.on_channel_status(status.into()), + Err(e) => error!("Error fetching liveview status: {e}"), + } + } + new_status = socket_status => { + match new_status { + Ok(status) => state.on_socket_status(status), + Err(e) => error!("Error fetching liveview status: {e}"), + } + } + } + } + + if view_refresh_needed { + state.refresh_view(socket_reconnected); + } + } + }); + + Self { + msg_tx, + main_background_task, + } + } + + /// This must be called after any function which successfully + /// changes the underlying live channel, if `socket_reconnected` is true + /// listeners to refresh events will also be notified. `socket_reconnected` is + /// the equivalent of a full liveview reload like a `live_redirect` or an web + /// page reload. + pub fn refresh_view(&self, socket_reconnected: bool) { + let _ = self + .msg_tx + .send(ClientMessage::RefreshView { socket_reconnected }); + } + + pub async fn handle_navigation_summary( + &self, + summary: Result, + ) -> Result { + match summary { + Ok(res) => { + self.refresh_view(res.websocket_reconnected); + Ok(res.history_id) + } + Err(LiveSocketError::JoinRejection { error }) => { + let mut result = self.handle_navigation_error(&error).await; + let mut retry_count = 0; + + while let Err(LiveSocketError::JoinRejection { error }) = &result { + if retry_count > MAX_REDIRECTS { + return result; + } + result = self.handle_navigation_error(error).await; + retry_count += 1; + } + + result + } + Err(e) => Err(e), + } + } + + /// During navigation sometimes an error containing `live_redirects` can + /// be emitted. these errors are not forwarded to the main event loop by default + /// so we forward them here. + async fn handle_navigation_error( + &self, + payload: &Payload, + ) -> Result { + let (tx, result) = oneshot::channel(); + + let _ = self.msg_tx.send(ClientMessage::HandleSocketReply { + payload: payload.clone(), + tx, + }); + + let action = result.await.map_err(|_| LiveSocketError::Call { + error: String::from("Response cancelled while handling navigation error"), + })??; + + match action { + ReplyAction::Redirected { id } => Ok(id), + _ => Err(LiveSocketError::JoinRejection { + error: payload.clone(), + }), + } + } + + pub fn create_handle(&self) -> LiveViewClientChannel { + let msg_tx = self.msg_tx.clone(); + LiveViewClientChannel { + message_sender: msg_tx, + } + } +} + +impl Drop for EventLoop { + fn drop(&mut self) { + self.main_background_task.abort(); + } +} diff --git a/crates/core/src/client/inner/event_loop/state.rs b/crates/core/src/client/inner/event_loop/state.rs new file mode 100644 index 000000000..e5858e628 --- /dev/null +++ b/crates/core/src/client/inner/event_loop/state.rs @@ -0,0 +1,409 @@ +use std::{future::Future, sync::Arc}; + +use futures::FutureExt; +use log::error; +use phoenix_channels_client::{ + CallError, ChannelStatus, ChannelStatuses, Event, EventPayload, Events, EventsError, Payload, + PhoenixEvent, Socket, SocketStatus, SocketStatuses, StatusesError, WebSocketError, JSON, +}; +use tokio::select; + +use super::{ClientMessage, LiveViewClientState, NetworkEventHandler}; +use crate::{ + client::{HistoryId, LiveChannelStatus}, + dom::ffi::{self, Document}, + error::LiveSocketError, + live_socket::{ + navigation::{NavAction, NavOptions}, + LiveChannel, + }, + protocol::{LiveRedirect, RedirectKind}, +}; + +pub enum ReplyAction { + Redirected { id: HistoryId }, + DiffMerged, + None, +} + +pub struct EventLoopState { + /// The DOM for the current view + document: ffi::Document, + /// The main live view channel which handles the document updates + live_view_channel: ChannelState, + /// The live view channel which handles asset changes on the server for reloading purposes + live_reload: Option, + /// The current socket status stream + socket_statuses: Arc, + network_handler: Option>, + client_state: Arc, +} + +struct ChannelState { + channel: Arc, + events: Arc, + statuses: Arc, +} + +impl From> for ChannelState { + fn from(value: Arc) -> Self { + ChannelState { + events: value.channel.events(), + statuses: value.channel.statuses(), + channel: value, + } + } +} + +impl EventLoopState { + pub fn new(client_state: Arc) -> Self { + let channel = client_state + .liveview_channel + .lock() + .expect("lock poison") + .clone(); + + let live_reload = client_state + .livereload_channel + .lock() + .expect("lock poison") + .clone() + .map(|c| c.into()); + + Self { + document: channel.document.clone(), + live_reload, + socket_statuses: channel.socket.statuses(), + live_view_channel: channel.into(), + network_handler: client_state.config.network_event_handler.clone(), + client_state, + } + } + + async fn cast(&self, event: Event, payload: Payload) -> Result<(), LiveSocketError> { + self.live_view_channel + .channel + .channel + .cast(event, payload) + .await + .map_err(|e| LiveSocketError::Cast { + error: format!("{e:?}"), + })?; + + Ok(()) + } + + async fn call(&self, event: Event, payload: Payload) -> Result { + let timeout = self.live_view_channel.channel.timeout; + let res = self + .live_view_channel + .channel + .channel + .call(event, payload, timeout) + .await?; + Ok(res) + } + + /// Called when the owning `LiveViewClient` has been updated + /// and has a new valid live channel - livereaload channel, and/or live socket. + pub fn refresh_view(&mut self, socket_reconnect: bool) { + let new_live_channel = self.client_state.liveview_channel.lock().unwrap().clone(); + self.socket_statuses = new_live_channel.socket.statuses(); + self.live_view_channel = ChannelState::from(new_live_channel.clone()); + self.document = new_live_channel.document.clone(); + + if let Some(doc_change) = &self.client_state.config.patch_handler { + self.document.arc_set_event_handler(doc_change.clone()) + } + + let new_livereload_channel = self.client_state.livereload_channel.lock().unwrap().clone(); + self.live_reload = new_livereload_channel.map(ChannelState::from); + + self.on_reload( + self.document.clone().into(), + self.live_view_channel.channel.clone(), + self.live_view_channel.channel.socket.clone(), + socket_reconnect, + ) + } + + pub fn event_futures( + &self, + ) -> ( + impl Future> + '_, + impl Future> + '_, + impl Future> + '_, + ) { + let server_event = self.live_view_channel.events.event().fuse(); + let chan_status = self.live_view_channel.statuses.status().fuse(); + let maybe_reload_event = self.live_reload.as_ref().map(|l| l.events.event()); + + let socket_status = self.socket_statuses.status(); + + let socket_status = async { + match socket_status.await { + Ok(res) => res, + Err(_) => std::future::pending().await, + } + } + .fuse(); + + let live_reload_proxy = async { + match maybe_reload_event { + Some(e) => e.await, + None => std::future::pending().await, + } + } + .fuse(); + + let server_event = async { + select! { + r1 = live_reload_proxy => r1, + r2 = server_event => r2, + } + } + .fuse(); + + (server_event, chan_status, socket_status) + } + + /// Call the user provided call back for receiving a + pub fn on_event(&self, event: EventPayload) { + if let Some(handler) = &self.network_handler { + handler.handle_event(event); + } + } + + pub fn on_channel_status(&self, status: LiveChannelStatus) { + if let Some(handler) = &self.network_handler { + handler.handle_channel_status_change(status); + } + } + + pub fn on_socket_status(&self, status: SocketStatus) { + if let Some(handler) = &self.network_handler { + handler.handle_socket_status_change(status); + } + } + + pub fn on_reload( + &self, + new_document: Arc, + new_channel: Arc, + current_socket: Arc, + socket_is_new: bool, + ) { + if let Some(handler) = &self.network_handler { + handler.handle_view_reloaded(new_document, new_channel, current_socket, socket_is_new); + } + } + + pub async fn handle_client_message( + &self, + message: ClientMessage, + channel_updated: &mut bool, + socket_updated: &mut bool, + ) { + match message { + ClientMessage::Call { + response_tx, + event, + payload, + } => { + let call_result = self.call(event, payload).await; + + match call_result { + Ok(reply) => { + if let Err(e) = self + .handle_reply(&reply, channel_updated, socket_updated) + .await + { + error!("Failure while handling server reply: {e:?}"); + } + + let event = EventPayload { + event: Event::Phoenix { + phoenix: PhoenixEvent::Reply, + }, + payload: reply.clone(), + }; + + self.on_event(event); + + let _ = response_tx.send(Ok(reply)); + } + Err(e) => { + error!("Remote call returned error: {e:?}"); + let _ = response_tx.send(Err(e)); + } + } + } + ClientMessage::Cast { event, payload } => { + let _ = self.cast(event, payload).await; + } + ClientMessage::RefreshView { socket_reconnected } => { + *channel_updated = true; + *socket_updated = socket_reconnected + } + ClientMessage::HandleSocketReply { payload, tx } => { + let result = self + .handle_reply(&payload, channel_updated, socket_updated) + .await; + + let event = EventPayload { + event: Event::Phoenix { + phoenix: PhoenixEvent::Reply, + }, + payload, + }; + + self.on_event(event); + + let _ = tx.send(result); + } + } + } + + async fn handle_redirect( + &self, + redirect: &JSON, + _channel_updated: &mut bool, + _socket_updated: &mut bool, + ) -> Result { + let json = redirect.clone().into(); + let redirect: LiveRedirect = serde_json::from_value(json)?; + let url = self.client_state.session_data.try_lock()?.url.clone(); + let url = url.join(&redirect.to)?; + + let action = match redirect.kind { + Some(RedirectKind::Push) | None => NavAction::Push, + Some(RedirectKind::Replace) => NavAction::Replace, + }; + + let opts = NavOptions { + action: Some(action), + join_params: self.live_view_channel.channel.join_params.clone().into(), + ..NavOptions::default() + }; + + let res = self.client_state.navigate(url.to_string(), opts).await?; + Ok(res.history_id) + } + + async fn handle_reply( + &self, + reply: &Payload, + channel_updated: &mut bool, + socket_updated: &mut bool, + ) -> Result { + let Payload::JSONPayload { + json: JSON::Object { object }, + } = reply + else { + return Ok(ReplyAction::None); + }; + + if let Some(object) = object.get("live_redirect") { + let id = self + .handle_redirect(object, channel_updated, socket_updated) + .await?; + + return Ok(ReplyAction::Redirected { id }); + } + + if let Some(object) = object.get("redirect") { + let id = self + .handle_redirect(object, channel_updated, socket_updated) + .await?; + return Ok(ReplyAction::Redirected { id }); + } + + if let Some(diff) = object.get("diff") { + self.document + .merge_deserialized_fragment_json(diff.clone())?; + + return Ok(ReplyAction::DiffMerged); + }; + + Ok(ReplyAction::None) + } + + pub async fn handle_server_event( + &self, + event: &EventPayload, + channel_updated: &mut bool, + socket_updated: &mut bool, + ) -> Result<(), LiveSocketError> { + match &event.event { + Event::Phoenix { phoenix } => { + error!("Phoenix Event for {phoenix:?} is unimplemented"); + } + Event::User { user } => match user.as_str() { + "diff" => { + let Payload::JSONPayload { json } = &event.payload else { + error!("Diff was not json!"); + return Ok(()); + }; + + self.document + .merge_deserialized_fragment_json(json.clone())?; + } + "assets_change" => { + let Some(current_entry) = self.client_state.current_history_entry() else { + return Ok(()); + }; + + let opts = self + .client_state + .session_data + .try_lock()? + .connect_opts + .clone(); + + let join_params = self.live_view_channel.channel.join_params.clone(); + + self.client_state + .reconnect(current_entry.url, opts, Some(join_params)) + .await?; + + *socket_updated = true; + *channel_updated = true; + } + "live_patch" => { + let Payload::JSONPayload { json, .. } = &event.payload else { + error!("Live patch was not json!"); + return Ok(()); + }; + + let json = json.clone().into(); + let redirect: LiveRedirect = serde_json::from_value(json)?; + + self.client_state.patch(redirect.to)?; + } + "live_redirect" => { + let Payload::JSONPayload { json, .. } = &event.payload else { + error!("Live redirect was not json!"); + return Ok(()); + }; + + // respect `to` `kind` and `mode` relative to current url base + self.handle_redirect(json, channel_updated, socket_updated) + .await?; + } + "redirect" => { + let Payload::JSONPayload { json, .. } = &event.payload else { + error!("Live redirect was not json!"); + return Ok(()); + }; + + // navigate replacing top, using `to` relative to current url base + self.handle_redirect(json, channel_updated, socket_updated) + .await?; + } + _ => {} + }, + }; + + Ok(()) + } +} diff --git a/crates/core/src/client/inner/logging.rs b/crates/core/src/client/inner/logging.rs new file mode 100644 index 000000000..64d61237e --- /dev/null +++ b/crates/core/src/client/inner/logging.rs @@ -0,0 +1,129 @@ +use std::sync::Once; + +use log::LevelFilter; + +use crate::client::LogLevel; + +static INIT_LOG: Once = Once::new(); + +impl From for LevelFilter { + fn from(level: LogLevel) -> Self { + match level { + LogLevel::Trace => LevelFilter::Trace, + LogLevel::Debug => LevelFilter::Debug, + LogLevel::Info => LevelFilter::Info, + LogLevel::Warn => LevelFilter::Warn, + LogLevel::Error => LevelFilter::Error, + } + } +} + +pub fn init_log(level: LogLevel) { + INIT_LOG.call_once(|| { + platform::init_log(level); + }); +} + +pub fn set_log_level(level: LogLevel) { + log::set_max_level(level.into()) +} + +#[cfg(all(target_os = "android", not(test)))] +mod platform { + use super::*; + + pub fn init_log(level: LogLevel) { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(level.into()) + .with_tag("LiveViewNative") + .format(|f, record| { + if record.level() == log::Level::Error { + writeln!( + f, + "[{}] {} {}:{} - {}", + record.level(), + record.target(), + record.file().unwrap_or("unknown"), + record + .line() + .map(|line| line.to_string()) + .as_deref() + .unwrap_or("unknown"), + record.args() + ) + } else { + writeln!( + f, + "[{}] {} - {}", + record.level(), + record.target(), + record.args() + ) + } + }), + ); + } +} + +#[cfg(all(target_vendor = "apple", not(test)))] +mod platform { + use super::*; + + pub fn init_log(level: LogLevel) { + if let Err(e) = oslog::OsLogger::new("com.liveview.core.lib") + .level_filter(level.into()) + // For some reason uniffi really loves printing every fn call, for a dom, that sucks + .category_level_filter("liveview_native_core::dom::node", LevelFilter::Warn) + .category_level_filter("liveview_native_core::dom::ffi", LevelFilter::Warn) + .init() + { + eprintln!("{e}"); + } + } +} + +#[cfg(any(test, not(any(target_os = "android", target_vendor = "apple"))))] +mod platform { + use std::io::Write; + + use env_logger::{Builder, Env}; + + use super::*; + + pub fn init_log(level: LogLevel) { + let env = Env::default(); + let mut builder = Builder::from_env(env); + let _ = builder + .is_test(cfg!(test)) + .format(|formatter, record| { + if record.level() == log::Level::Error { + writeln!( + formatter, + "[{}] {} {}:{} - {}", + record.level(), + record.target(), + record.file().unwrap_or("unknown"), + record + .line() + .map(|line| line.to_string()) + .as_deref() + .unwrap_or("unknown"), + record.args() + ) + } else { + writeln!( + formatter, + "[{}] {} - {}", + record.level(), + record.target(), + record.args() + ) + } + }) + .filter(None, level.into()) + .filter(Some("liveview_native_core::dom::node"), LevelFilter::Warn) + .filter(Some("liveview_native_core::dom::ffi"), LevelFilter::Warn) + .try_init(); + } +} diff --git a/crates/core/src/client/inner/mod.rs b/crates/core/src/client/inner/mod.rs new file mode 100644 index 000000000..34c16ad6f --- /dev/null +++ b/crates/core/src/client/inner/mod.rs @@ -0,0 +1,601 @@ +mod channel_init; +mod cookie_store; +mod event_loop; +mod logging; +mod navigation; + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; + +use channel_init::*; +use cookie_store::PersistentCookieStore; +use event_loop::EventLoop; +pub(crate) use event_loop::LiveViewClientChannel; +use log::debug; +use logging::*; +use navigation::NavCtx; +use phoenix_channels_client::{Payload, Socket, SocketStatus, JSON}; +use reqwest::{redirect::Policy, Client, Url}; + +use super::{ClientConnectOpts, LiveViewClientConfiguration, LogLevel}; +use crate::{ + callbacks::*, + dom::{ffi::Document as FFIDocument, Document}, + error::LiveSocketError, + live_socket::{ + navigation::{NavActionOptions, NavOptions}, + ConnectOpts, LiveChannel, LiveFile, SessionData, + }, +}; + +pub(crate) struct LiveViewClientState { + /// Manages navigation state, events + config: LiveViewClientConfiguration, + /// A book keeping context for navigation events. + nav_ctx: Mutex, + /// HTTP client used to request dead renders. + http_client: Client, + /// The main websocket for this page + socket: Mutex>, + /// The main channel which passes user interaction events + /// and receives changes to the "dom" + liveview_channel: Mutex>, + /// In debug mode LiveView has a debug channel which sends + /// asset update events, this is derived from an iframe on the page + /// which also may be present on errored out connections. + livereload_channel: Mutex>>, + /// Data acquired from the dead render, should only change between + /// reconnects. + session_data: Mutex, + /// Responsible for holding cookies and serializing them to disk + cookie_store: Arc, +} + +pub struct LiveViewClientInner { + state: Arc, + /// A long polling task on the current websocket, + /// because the socket is in an Arc for uniffi reasons the + /// `event_loop` must be notified whenever it changes with [EventLoop::refresh_view] + event_loop: EventLoop, +} + +struct NavigationSummary { + history_id: HistoryId, + websocket_reconnected: bool, +} + +// First implement the accessor methods on LiveViewClientInner +impl LiveViewClientInner { + pub async fn initial_connect( + config: LiveViewClientConfiguration, + url: String, + client_opts: ClientConnectOpts, + ) -> Result { + let state = LiveViewClientState::initial_connect(config, url, client_opts).await?; + let state = Arc::new(state); + let event_loop = EventLoop::new(state.clone()); + let out = Self { state, event_loop }; + Ok(out) + } + + pub(crate) async fn reconnect( + &self, + url: String, + opts: ConnectOpts, + join_params: Option>, + ) -> Result<(), LiveSocketError> { + self.state.reconnect(url, opts, join_params).await?; + self.event_loop.refresh_view(true); + Ok(()) + } + + pub(crate) async fn disconnect(&self) -> Result<(), LiveSocketError> { + let socket = self.state.socket.try_lock()?.clone(); + let _ = socket.disconnect().await; + self.event_loop.refresh_view(true); + Ok(()) + } + + pub async fn upload_file(&self, file: Arc) -> Result<(), LiveSocketError> { + let chan = self.channel()?; + chan.upload_file(&file).await?; + Ok(()) + } + + pub fn get_phx_upload_id(&self, phx_target_name: &str) -> Result { + self.state + .liveview_channel + .try_lock()? + .get_phx_upload_id(phx_target_name) + } + + pub fn channel(&self) -> Result, LiveSocketError> { + Ok(self.state.liveview_channel.try_lock()?.clone()) + } + + // TODO: a live reload channel is distinct from a live channel in a couple important + // ways, it should be it's own struct. + pub fn live_reload_channel(&self) -> Result>, LiveSocketError> { + Ok(self.state.livereload_channel.try_lock()?.clone()) + } + + pub fn join_url(&self) -> Result { + Ok(self.state.session_data.try_lock()?.url.to_string()) + } + + pub fn csrf_token(&self) -> Result { + Ok(self.state.session_data.try_lock()?.csrf_token.clone()) + } + + /// Returns the current document state. + pub fn document(&self) -> Result { + Ok(self.state.liveview_channel.try_lock()?.document()) + } + + /// Returns the state of the document upon the initial websocket connection. + pub fn join_document(&self) -> Result { + self.state.liveview_channel.try_lock()?.join_document() + } + + /// returns the join payload + pub fn join_payload(&self) -> Result { + Ok(self.state.liveview_channel.try_lock()?.join_payload.clone()) + } + + /// To establish the websocket connection, the client depends on an initial HTTP + /// request pull an html document and extract several pieces of meta data from it. + /// This function returns that initial document. + pub fn dead_render(&self) -> Result { + Ok(self.state.session_data.try_lock()?.dead_render.clone()) + } + + pub fn style_urls(&self) -> Result, LiveSocketError> { + Ok(self.state.session_data.try_lock()?.style_urls.clone()) + } + + pub fn status(&self) -> Result { + Ok(self.state.socket.try_lock()?.status()) + } + + pub async fn navigate( + &self, + url: String, + opts: NavOptions, + ) -> Result { + let res = self.state.navigate(url, opts).await; + self.event_loop.handle_navigation_summary(res).await + } + + pub async fn reload(&self, info: NavActionOptions) -> Result { + let res = self.state.reload(info).await; + self.event_loop.handle_navigation_summary(res).await + } + + pub async fn back(&self, info: NavActionOptions) -> Result { + let res = self.state.back(info).await; + self.event_loop.handle_navigation_summary(res).await + } + + pub async fn forward(&self, info: NavActionOptions) -> Result { + let res = self.state.forward(info).await; + self.event_loop.handle_navigation_summary(res).await + } + + pub async fn traverse_to( + &self, + id: HistoryId, + info: NavActionOptions, + ) -> Result { + let res = self.state.traverse_to(id, info).await; + self.event_loop.handle_navigation_summary(res).await + } + + pub fn can_go_back(&self) -> bool { + self.state.can_go_back() + } + + pub fn can_go_forward(&self) -> bool { + self.state.can_go_forward() + } + + pub fn can_traverse_to(&self, id: HistoryId) -> bool { + self.state.can_traverse_to(id) + } + + pub fn get_entries(&self) -> Vec { + self.state.get_history_entries() + } + + pub fn current_history_entry(&self) -> Option { + self.state.current_history_entry() + } + + pub fn create_channel(&self) -> LiveViewClientChannel { + self.event_loop.create_handle() + } + + pub fn set_log_level(&self, level: LogLevel) { + set_log_level(level) + } +} + +impl LiveViewClientState { + /// The first connection and initialization, fetches the dead render and opens the default channel. + pub async fn initial_connect( + config: LiveViewClientConfiguration, + url: String, + client_opts: ClientConnectOpts, + ) -> Result { + init_log(config.log_level); + debug!("Initializing LiveViewClient."); + debug!("LiveViewCore Version: {}", env!("CARGO_PKG_VERSION")); + debug!("Configuration: {config:?}"); + + let cookie_store: Arc<_> = + PersistentCookieStore::new(config.persistence_provider.clone()).into(); + + let http_client = Client::builder() + .cookie_provider(cookie_store.clone()) + .redirect(Policy::none()) + .build() + .expect("Failed to build HTTP client"); + + let url = Url::parse(&url)?; + let format = config.format.to_string(); + + let opts = ConnectOpts { + headers: client_opts.headers, + ..ConnectOpts::default() + }; + + debug!("Retrieving session data from: {url:?}"); + let session_data = SessionData::request(&url, &format, opts, http_client.clone()).await?; + + let cookies = cookie_store.get_cookie_list(&url); + + let websocket_url = session_data.get_live_socket_url()?; + + let session_data = Mutex::new(session_data); + + log::info!("Initiating Websocket connection: {websocket_url:?} , cookies: {cookies:?}"); + let socket = Socket::spawn(websocket_url, cookies.clone()).await?; + let socket = Mutex::new(socket); + + let ws_timeout = Duration::from_millis(config.websocket_timeout); + debug!("Joining liveview Channel"); + let liveview_channel = join_liveview_channel( + &socket, + &session_data, + &client_opts.join_params, + None, + ws_timeout, + ) + .await?; + + if let Some(handler) = &config.patch_handler { + liveview_channel + .document + .arc_set_event_handler(handler.clone()) + } + + let livereload_channel = if session_data.try_lock()?.has_live_reload { + debug!("Joining liveReload Channel"); + join_livereload_channel(&config, &socket, &session_data, cookies) + .await? + .into() + } else { + None + }; + + let mut nav_ctx = NavCtx::default(); + nav_ctx.navigate(url.clone(), NavOptions::default(), false); + + if let Some(handler) = &config.navigation_handler { + nav_ctx.set_event_handler(handler.clone()); + } + + Ok(Self { + config, + http_client, + socket, + session_data, + nav_ctx: nav_ctx.into(), + liveview_channel: liveview_channel.into(), + livereload_channel: livereload_channel.into(), + cookie_store, + }) + } + + /// Reconnect the websocket at the given url + pub async fn reconnect( + &self, + url: String, + opts: ConnectOpts, + join_params: Option>, + ) -> Result<(), LiveSocketError> { + debug!("Reestablishing connection with settings: url: {url:?}, opts: {opts:?}"); + + let url = Url::parse(&url)?; + let new_session = SessionData::request( + &url, + &self.config.format.to_string(), + opts, + self.http_client.clone(), + ) + .await?; + + let websocket_url = new_session.get_live_socket_url()?; + + let cookies = self.cookie_store.get_cookie_list(&url); + + debug!("Initiating new websocket connection: {websocket_url:?}"); + let socket = Socket::spawn(websocket_url, cookies.clone()).await?; + + let old_socket = self.socket.try_lock()?.clone(); + let _ = old_socket.disconnect().await; + + *self.socket.try_lock()? = socket; + + *self.session_data.try_lock()? = new_session; + let ws_timeout = Duration::from_millis(self.config.websocket_timeout); + debug!("Rejoining liveview channel"); + let new_channel = join_liveview_channel( + &self.socket, + &self.session_data, + &join_params, + None, + ws_timeout, + ) + .await?; + + if let Some(handler) = &self.config.patch_handler { + new_channel.document.arc_set_event_handler(handler.clone()); + } + + *self.liveview_channel.try_lock()? = new_channel; + let has_reload = self.session_data.try_lock()?.has_live_reload; + + if has_reload { + debug!("Rejoining livereload channel"); + let new_livereload = + join_livereload_channel(&self.config, &self.socket, &self.session_data, cookies) + .await?; + *self.livereload_channel.try_lock()? = Some(new_livereload); + } + + Ok(()) + } +} + +impl LiveViewClientState { + const RETRY_REASONS: &[&str] = &["stale", "unauthorized"]; + /// try to do the internal nav, attempting to fix + /// recoverable errors which occur when connecting across + /// auth state, live_sessions and respecting redirects. + /// If the websocket needed to be refreshed this returns true + /// otherwise it returns false. + async fn try_nav( + &self, + additional_params: &Option>, + ) -> Result { + let current = self + .nav_ctx + .try_lock()? + .current() + .ok_or(LiveSocketError::NavigationImpossible)?; + + let ws_timeout = Duration::from_millis(self.config.websocket_timeout); + + let chan = self.liveview_channel.try_lock()?.channel(); + chan.leave().await?; + + match join_liveview_channel( + &self.socket, + &self.session_data, + additional_params, + Some(current.url.clone()), + ws_timeout, + ) + .await + { + Err(LiveSocketError::JoinRejection { + error: + Payload::JSONPayload { + json: JSON::Object { object }, + }, + }) => { + if object + .get("reason") + .and_then(|r| match r { + JSON::Str { string } => Some(string), + _ => None, + }) + .is_none_or(|reason| !Self::RETRY_REASONS.contains(&reason.as_str())) + { + return Err(LiveSocketError::JoinRejection { + error: Payload::JSONPayload { + json: JSON::Object { object }, + }, + }); + } + + let url = Url::parse(¤t.url)?; + let format = self.config.format.to_string(); + + let new_session_data = SessionData::request( + &url, + &format, + Default::default(), + self.http_client.clone(), + ) + .await?; + + let websocket_url = new_session_data.get_live_socket_url()?; + + let new_socket = + Socket::spawn(websocket_url, Some(new_session_data.cookies.clone())).await?; + + let sock = self.socket.try_lock()?.clone(); + sock.shutdown() + .await + .map_err(|e| LiveSocketError::Phoenix { + error: format!("{e:?}"), + })?; + + *self.socket.try_lock()? = new_socket; + *self.session_data.try_lock()? = new_session_data; + + let channel = join_liveview_channel( + &self.socket, + &self.session_data, + additional_params, + None, + ws_timeout, + ) + .await?; + + if let Some(event_handler) = &self.config.patch_handler { + channel + .document + .arc_set_event_handler(event_handler.clone()) + } + + let chan = &self.liveview_channel.try_lock()?.channel.clone(); + chan.leave().await?; + + *self.liveview_channel.try_lock()? = channel; + Ok(true) + } + Ok(channel) => { + let chan = &self.liveview_channel.try_lock()?.channel.clone(); + chan.leave().await?; + + if let Some(event_handler) = &self.config.patch_handler { + channel + .document + .arc_set_event_handler(event_handler.clone()) + } + + *self.liveview_channel.try_lock()? = channel; + Ok(false) + } + Err(e) => Err(e), + } + } + + async fn try_nav_outer( + &self, + additional_params: &Option>, + nav_action: F, + ) -> Result + where + F: FnOnce(&mut NavCtx) -> Option, + { + // try the navigation action, if it's impossible the returned + // history id will be None. + let new_id = { + let mut nav_ctx = self.nav_ctx.try_lock()?; + nav_action(&mut nav_ctx) + }; + + match new_id { + Some(id) => { + // actually do the navigation, updating everything in one fell swoop + let websocket_reconnected = self.try_nav(additional_params).await?; + Ok(NavigationSummary { + history_id: id, + websocket_reconnected, + }) + } + None => Err(LiveSocketError::NavigationImpossible), + } + } + + async fn navigate( + &self, + url: String, + opts: NavOptions, + ) -> Result { + let url = Url::parse(&url)?; + self.try_nav_outer(&opts.join_params.clone(), |ctx| { + ctx.navigate(url, opts, true) + }) + .await + } + + async fn reload(&self, opts: NavActionOptions) -> Result { + self.try_nav_outer(&opts.join_params, |ctx| { + ctx.reload(opts.extra_event_info, true) + }) + .await + } + + async fn back(&self, opts: NavActionOptions) -> Result { + self.try_nav_outer(&opts.join_params, |ctx| { + ctx.back(opts.extra_event_info, true) + }) + .await + } + + async fn forward(&self, opts: NavActionOptions) -> Result { + self.try_nav_outer(&opts.join_params, |ctx| { + ctx.forward(opts.extra_event_info, true) + }) + .await + } + + fn patch(&self, url_path: String) -> Result { + let id = self.nav_ctx.try_lock()?.patch(url_path, true); + + Ok(NavigationSummary { + history_id: id.unwrap_or(0), + websocket_reconnected: false, + }) + } + + async fn traverse_to( + &self, + id: HistoryId, + opts: NavActionOptions, + ) -> Result { + self.try_nav_outer(&opts.join_params, |ctx| { + ctx.traverse_to(id, opts.extra_event_info, true) + }) + .await + } + + fn can_go_back(&self) -> bool { + self.nav_ctx + .try_lock() + .map(|ctx| ctx.can_go_back()) + .unwrap_or(false) + } + + fn can_go_forward(&self) -> bool { + self.nav_ctx + .try_lock() + .map(|ctx| ctx.can_go_forward()) + .unwrap_or(false) + } + + fn can_traverse_to(&self, id: HistoryId) -> bool { + self.nav_ctx + .try_lock() + .map(|ctx| ctx.can_traverse_to(id)) + .unwrap_or(false) + } + + fn get_history_entries(&self) -> Vec { + self.nav_ctx + .try_lock() + .map(|ctx| ctx.entries()) + .unwrap_or_default() + } + + fn current_history_entry(&self) -> Option { + self.nav_ctx.try_lock().ok().and_then(|ctx| ctx.current()) + } +} diff --git a/crates/core/src/client/inner/navigation.rs b/crates/core/src/client/inner/navigation.rs new file mode 100644 index 000000000..e21572fe4 --- /dev/null +++ b/crates/core/src/client/inner/navigation.rs @@ -0,0 +1,489 @@ +use std::sync::Arc; + +use reqwest::Url; + +use crate::{ + callbacks::*, + live_socket::navigation::{NavAction, NavOptions}, +}; + +#[derive(Clone, Default)] +struct HandlerInternal(pub Option>); + +impl std::fmt::Debug for HandlerInternal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.0.is_some() { + write!(f, "Handler Active")?; + } else { + write!(f, "No Handler Present")?; + }; + Ok(()) + } +} + +/// The internal navigation context. +/// handles the history state of the visited views. +#[derive(Debug, Clone, Default)] +pub struct NavCtx { + /// Previously visited views + history: Vec, + /// Views that are "forward" in history + future: Vec, + /// monotonically increasing ID for `NavHistoryEntry` + id_source: HistoryId, + /// user provided callback + navigation_event_handler: HandlerInternal, +} + +impl NavCtx { + /// Navigate to `url` with behavior and metadata specified in `opts`. + /// Returns the current history ID if changed + pub fn navigate(&mut self, url: Url, opts: NavOptions, emit_event: bool) -> Option { + let action = opts.action.clone(); + let next_dest = self.speculative_next_dest(&url, opts.state.clone()); + let next_id = next_dest.id; + + let event = { + let new_dest = next_dest.clone(); + let old_dest = self.current(); + let event = match opts.action { + Some(NavAction::Replace) => NavEventType::Replace, + _ => NavEventType::Push, + }; + + NavEvent::new(event, new_dest, old_dest, opts.extra_event_info) + }; + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => {} + HandlerResponse::PreventDefault => return None, + }; + + match action { + Some(NavAction::Replace) => self.replace_entry(next_dest), + None | Some(NavAction::Push) => self.push_entry(next_dest), + } + + // successful navigation invalidates previously coalesced state from + // calls to `back` + self.future.clear(); + Some(next_id) + } + + pub fn patch(&mut self, url_path: String, emit_event: bool) -> Option { + let old_dest = self.current()?; + let old_id = old_dest.id; + let old_url = Url::parse(&old_dest.url).ok()?; + let new_url = old_url.join(&url_path).ok()?; + + let new_dest = + NavHistoryEntry::new(new_url.to_string(), old_dest.id, old_dest.state.clone()); + + let event = NavEvent::new(NavEventType::Patch, new_dest.clone(), old_dest.into(), None); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => { + self.replace_entry(new_dest); + } + HandlerResponse::PreventDefault => return None, + }; + + Some(old_id) + } + + // Returns true if the navigator can go back one entry. + pub fn can_go_back(&self) -> bool { + self.history.len() >= 2 + } + + // Returns true if the navigator can go forward one entry. + pub fn can_go_forward(&self) -> bool { + !self.future.is_empty() + } + + // Returns true if the `id` is tracked in the navigation context. + pub fn can_traverse_to(&self, id: HistoryId) -> bool { + let hist = self.history.iter().find(|ent| ent.id == id); + let fut = self.future.iter().find(|ent| ent.id == id); + hist.or(fut).is_some() + } + + // Returns all of the tracked history entries by cloning them. + // They are in traversal sequence order, with no guarantees about + // the position of the current entry. + pub fn entries(&self) -> Vec { + self.history + .iter() + .chain(self.future.iter().rev()) + .cloned() + .collect() + } + + /// Calls the handler for reload events + pub fn reload(&mut self, info: Option>, emit_event: bool) -> Option { + let current = self.current()?; + let id = current.id; + + let event = NavEvent::new(NavEventType::Reload, current.clone(), current.into(), info); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => {} + HandlerResponse::PreventDefault => return None, + }; + + Some(id) + } + + /// Navigates back one step in the stack, returning the id of the new + /// current entry if successful. + /// This function fails if there is no current + /// page or if there are no items in history and returns [None]. + pub fn back(&mut self, info: Option>, emit_event: bool) -> Option { + if !self.can_go_back() { + log::warn!("Attempted `back` navigation without at minimum two entries."); + return None; + } + + let previous = self.current()?; + + let next = self.history[self.history.len() - 2].clone(); + + let event = { + let new_dest = next.clone(); + let old_dest = previous.clone(); + NavEvent::new(NavEventType::Push, new_dest, Some(old_dest), info) + }; + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => { + let previous = self.history.pop()?; + let out = Some(next.id); + self.future.push(previous); + out + } + HandlerResponse::PreventDefault => None, + } + } + + /// Navigate one step forward, fails if there is not at least one + /// item in the history and future stacks. + pub fn forward(&mut self, info: Option>, emit_event: bool) -> Option { + if !self.can_go_forward() { + log::warn!( + "Attempted `future` navigation with an no current location or no next entry." + ); + return None; + } + + let next = self.future.last().cloned()?; + let previous = self.current(); + + let event = NavEvent::new(NavEventType::Push, next, previous, info); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => { + let next = self.future.pop()?; + let out = Some(next.id); + self.push_entry(next); + out + } + HandlerResponse::PreventDefault => None, + } + } + + pub fn traverse_to( + &mut self, + id: HistoryId, + info: Option>, + emit_event: bool, + ) -> Option { + if !self.can_traverse_to(id) { + log::warn!("Attempted to traverse to an untracked ID!"); + return None; + } + + let old_dest = self.current()?; + let in_hist = self.history.iter().position(|ent| ent.id == id); + if let Some(entry) = in_hist { + let new_dest = self.history[entry].clone(); + + let event = NavEvent::new(NavEventType::Traverse, new_dest, old_dest.into(), info); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => {} + HandlerResponse::PreventDefault => return None, + }; + + // All entries except the target + let ext = self.history.drain(entry + 1..); + self.future.extend(ext.rev()); + return Some(id); + } + + let in_fut = self.future.iter().position(|ent| ent.id == id); + if let Some(entry) = in_fut { + let new_dest = self.future[entry].clone(); + + let event = NavEvent::new(NavEventType::Traverse, new_dest, old_dest.into(), info); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => {} + HandlerResponse::PreventDefault => return None, + }; + + // All entries including the target, which will be at the front. + let ext = self.future.drain(entry..); + self.history.extend(ext.rev()); + return Some(id); + } + + None + } + + /// Returns the current history entry and state + pub fn current(&self) -> Option { + self.history.last().cloned() + } + + fn replace_entry(&mut self, history_entry: NavHistoryEntry) { + if let Some(last) = self.history.last_mut() { + self.id_source += 1; + + *last = history_entry + } else { + self.push_entry(history_entry) + } + } + + fn push_entry(&mut self, history_entry: NavHistoryEntry) { + self.id_source += 1; + self.history.push(history_entry); + } + + pub fn set_event_handler(&mut self, handler: Arc) { + self.navigation_event_handler.0 = Some(handler) + } + + pub fn handle_event(&mut self, event: NavEvent, emit_event: bool) -> HandlerResponse { + if !emit_event { + return HandlerResponse::Default; + } + + if let Some(handler) = self.navigation_event_handler.0.as_ref() { + handler.handle_event(event) + } else { + HandlerResponse::Default + } + } + + /// create a new destination if one would be added to history, this includes + /// the next unique ID that would be issued. + fn speculative_next_dest(&self, url: &Url, state: Option>) -> NavHistoryEntry { + NavHistoryEntry { + id: self.id_source + 1, + url: url.to_string(), + state, + } + } +} + +#[cfg(test)] +mod test { + use std::sync::Mutex; + + use super::*; + + // Mock event handler used to validate the internal + // navigation objects state. + pub struct NavigationInspector { + last_event: Mutex>, + } + + impl NavEventHandler for NavigationInspector { + fn handle_event(&self, event: NavEvent) -> HandlerResponse { + *self.last_event.lock().expect("Lock poisoned!") = Some(event); + HandlerResponse::Default + } + } + + impl NavigationInspector { + pub fn new() -> Self { + Self { + last_event: None.into(), + } + } + + pub fn last_event(&self) -> Option { + self.last_event.lock().expect("Lock poisoned!").clone() + } + } + + #[test] + fn basic_internal_nav() { + let handler = Arc::new(NavigationInspector::new()); + let mut ctx = NavCtx::default(); + ctx.set_event_handler(handler.clone()); + + // simple push nav + let url_str = "https://www.website.com/live"; + let url = Url::parse(url_str).expect("URL failed to parse"); + ctx.navigate(url, NavOptions::default(), true); + + assert_eq!( + NavEvent { + event: NavEventType::Push, + to: NavHistoryEntry { + state: None, + id: 1, + url: url_str.to_string(), + }, + ..NavEvent::empty() + }, + handler.last_event().expect("Missing Event") + ); + } + + #[test] + fn basic_internal_navigate_back() { + let handler = Arc::new(NavigationInspector::new()); + let mut ctx = NavCtx::default(); + ctx.set_event_handler(handler.clone()); + + // initial page + let first_url_str = "https://www.website.com/first"; + let url = Url::parse(first_url_str).expect("URL failed to parse"); + ctx.navigate(url, NavOptions::default(), true); + + // second page + let url_str = "https://www.website.com/second"; + let url = Url::parse(url_str).expect("URL failed to parse"); + ctx.navigate(url, NavOptions::default(), true) + .expect("Failed."); + + assert_eq!( + NavEvent { + to: NavHistoryEntry { + state: None, + id: 2, + url: url_str.to_string(), + }, + from: NavHistoryEntry { + state: None, + id: 1, + url: first_url_str.to_string(), + } + .into(), + ..NavEvent::empty() + }, + handler.last_event().expect("Missing Event") + ); + + //go back one view + ctx.back(None, true).expect("Failed Back."); + + assert_eq!( + NavEvent { + to: NavHistoryEntry { + state: None, + id: 1, + url: first_url_str.to_string(), + }, + from: NavHistoryEntry { + state: None, + id: 2, + url: url_str.to_string(), + } + .into(), + ..NavEvent::empty() + }, + handler.last_event().expect("Missing Event") + ); + } + + #[test] + fn test_navigation_with_state() { + let handler = Arc::new(NavigationInspector::new()); + let mut ctx = NavCtx::default(); + ctx.set_event_handler(handler.clone()); + + let url = Url::parse("https://example.com").expect("parse"); + let state = vec![1, 2, 3]; + let info = vec![4, 5, 6]; + + let opts = NavOptions { + state: Some(state.clone()), + extra_event_info: Some(info.clone()), + ..Default::default() + }; + + let id = ctx.navigate(url.clone(), opts, true).expect("nav"); + + let last_ev = handler.last_event().expect("no event."); + assert_eq!(last_ev.info, Some(info)); + + let current = ctx.current().expect("current"); + assert_eq!(current.id, id); + assert_eq!(current.state, Some(state)); + } + + #[test] + fn test_navigation_stack() { + let mut ctx = NavCtx::default(); + let first = Url::parse("https://example.com/first").expect("parse first"); + let second = Url::parse("https://example.com/second").expect("parse second"); + let third = Url::parse("https://example.com/third").expect("parse third"); + + let id1 = ctx + .navigate(first.clone(), NavOptions::default(), true) + .expect("nav first"); + let id2 = ctx + .navigate(second.clone(), NavOptions::default(), true) + .expect("nav second"); + let id3 = ctx + .navigate(third.clone(), NavOptions::default(), true) + .expect("nav third"); + + assert_eq!(ctx.current().expect("current").url, third.to_string()); + + let prev_id = ctx.back(None, true).expect("back"); + assert_eq!(prev_id, id2); + assert_eq!(ctx.current().expect("current").url, second.to_string()); + assert_eq!(ctx.entries().len(), 3); + + let next_id = ctx.forward(None, true).expect("forward"); + assert_eq!(next_id, id3); + assert_eq!(ctx.current().expect("current").url, third.to_string()); + assert_eq!(ctx.entries().len(), 3); + + ctx.traverse_to(id1, None, true) + .expect("Failed to traverse"); + assert_eq!(ctx.current().expect("current").url, first.to_string()); + assert_eq!(ctx.entries().len(), 3); + + ctx.traverse_to(id3, None, true) + .expect("Failed to traverse"); + assert_eq!(ctx.current().expect("current").url, third.to_string()); + assert_eq!(ctx.entries().len(), 3); + } + + #[test] + fn test_navigation_rollback_forward() { + let mut ctx = NavCtx::default(); + let first = Url::parse("https://example.com/first").expect("parse first"); + let second = Url::parse("https://example.com/second").expect("parse second"); + + let id1 = ctx + .navigate(first.clone(), NavOptions::default(), true) + .expect("nav first"); + + let id2 = ctx + .navigate(second.clone(), NavOptions::default(), true) + .expect("nav second"); + + ctx.back(None, true).expect("back"); + assert_eq!(ctx.current().expect("current").id, id1); + + ctx.forward(None, true).expect("forward"); + assert_eq!(ctx.current().expect("current").id, id2); + } +} diff --git a/crates/core/src/client/mod.rs b/crates/core/src/client/mod.rs new file mode 100644 index 000000000..66b830f3f --- /dev/null +++ b/crates/core/src/client/mod.rs @@ -0,0 +1,399 @@ +mod config; +pub(crate) mod inner; + +#[cfg(test)] +mod tests; + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use config::*; +use futures::future::try_join_all; +use inner::LiveViewClientInner; +use phoenix_channels_client::{Payload, SocketStatus, JSON}; +use reqwest::header::CONTENT_TYPE; + +use crate::{ + callbacks::*, + dom::ffi::{self}, + error::LiveSocketError, + live_socket::{ + navigation::{NavActionOptions, NavOptions}, + ConnectOpts, LiveChannel, LiveFile, Method, + }, +}; + +const CSRF_HEADER: &str = "x-csrf-token"; + +/// A configuration interface for building a [LiveViewClient]. +/// Options on this object will used for all http and websocket connections +/// through out the current session. +/// +/// Additionally provides the [LiveViewClient] with callbacks and essential functionality, +/// without proper configuration your client may not function properly. +/// See [LiveViewClientBuilder::set_persistence_provider] +#[derive(uniffi::Object, Default)] +pub struct LiveViewClientBuilder { + config: Mutex, +} + +#[uniffi::export(async_runtime = "tokio")] +impl LiveViewClientBuilder { + #[uniffi::constructor] + pub fn new() -> Self { + Self { + config: Default::default(), + } + } + + /// Provides the [LiveViewClient] with a way to store Cookies, and potentially other + /// user session data like settings. + pub fn set_persistence_provider(&self, provider: Box) { + let mut config = self.config.lock().unwrap(); + config.persistence_provider = Some(provider.into()); + } + + /// Provides the [LiveViewClient] with a way to store Cookies, and potentially other + /// user session data like settings. + pub fn set_live_channel_event_handler(&self, handler: Box) { + let mut config = self.config.lock().unwrap(); + config.network_event_handler = Some(handler.into()); + } + + /// The [DocumentChangeHandler] here will be called whenever a diff event + /// applies a change to the document that is being observed. By default, + /// no events will be emitted + pub fn set_patch_handler(&self, handler: Box) { + let mut config = self.config.lock().unwrap(); + config.patch_handler = Some(handler.into()); + } + + /// This is an endpoint intended for client developers to instrument navigation and + /// store view state. By default it permits all navigation. + pub fn set_navigation_handler(&self, handler: Box) { + let mut config = self.config.lock().unwrap(); + config.navigation_handler = Some(handler.into()); + } + + /// Set the log filter level. + /// + /// By Default the log filter is set to [LogLevel::Info] + pub fn set_log_level(&self, level: LogLevel) { + let mut config = self.config.lock().unwrap(); + config.log_level = level; + } + + /// Set the time out for establishign the initial http connection in milliseconds. + /// + /// By default the timeout is 30 seconds. + pub fn set_dead_render_timeout_ms(&self, timeout: u64) { + let mut config = self.config.lock().unwrap(); + config.dead_render_timeout = timeout; + } + + /// Set the time out for awaiting responses from the websocket in milliseconds. + /// + /// By default the timeout is 5 seconds. + pub fn set_websocket_timeout_ms(&self, timeout: u64) { + let mut config = self.config.lock().unwrap(); + config.websocket_timeout = timeout; + } + + /// Sets the '_format' arg set upon fetching the dead render and upon + /// establishing the websocket connection. + /// + /// On android this defaults to [Platform::Jetpack], on apple vendored + /// devices this defaults to [Platform::Swiftui], everywhere else it + /// defaults to "unknown", which will cause a connection failure. + pub fn set_format(&self, format: Platform) { + let mut config = self.config.lock().unwrap(); + config.format = format; + } + + /// Returns the current log level setting. + pub fn log_level(&self) -> LogLevel { + let config = self.config.lock().unwrap(); + config.log_level + } + + /// Returns the current dead render timeout in milliseconds. + pub fn dead_render_timeout(&self) -> u64 { + let config = self.config.lock().unwrap(); + config.dead_render_timeout + } + + /// Returns the current websocket timeout in milliseconds. + pub fn websocket_timeout(&self) -> u64 { + let config = self.config.lock().unwrap(); + config.websocket_timeout + } + + /// Returns the current platform format setting. + pub fn format(&self) -> Platform { + let config = self.config.lock().unwrap(); + config.format.clone() + } + + /// Attempt to establish a new, connected [LiveViewClient] with the param set above + pub async fn connect( + &self, + url: String, + opts: ClientConnectOpts, + ) -> Result { + let config = self.config.lock().unwrap().clone(); + let inner = LiveViewClientInner::initial_connect(config, url, opts).await?; + + Ok(LiveViewClient { inner }) + } +} + +/// The main entry for any LiveView native client. +/// It is initialized with a [LiveClientBuilder], using many +/// different callback objects to instrument a background event loop +/// which creates connections and responds to server events as needed. +#[derive(uniffi::Object)] +pub struct LiveViewClient { + inner: LiveViewClientInner, +} + +#[uniffi::export(async_runtime = "tokio")] +impl LiveViewClient { + pub async fn reconnect( + &self, + url: String, + client_opts: ClientConnectOpts, + ) -> Result<(), LiveSocketError> { + let opts = ConnectOpts { + headers: client_opts.headers, + body: client_opts.request_body, + method: client_opts.method, + ..Default::default() + }; + + self.inner + .reconnect(url, opts, client_opts.join_params) + .await?; + + Ok(()) + } + + pub async fn disconnect(&self) -> Result<(), LiveSocketError> { + self.inner.disconnect().await?; + Ok(()) + } + + /// Uploads the live files in `files` + /// + /// Note: currently the replies in the file upload work flow are + /// not responded to or respect in the main event loop, this means there will be + /// no progress updates as the file is uploaded. + pub async fn upload_files(&self, files: Vec>) -> Result<(), LiveSocketError> { + let chan = self.inner.channel()?; + let futs = files.iter().map(|file| chan.upload_file(file)); + try_join_all(futs).await?; + + Ok(()) + } + + /// Attempts to reconnect to a view by posting a form with fields `form` + /// reestablishing the liveview with `join_params` and using the headers provided + /// to fetch the dead render. automatically adds the content type header. + pub async fn post_form( + &self, + url: String, + form: HashMap, + join_params: Option>, + mut headers: Option>, + ) -> Result<(), LiveSocketError> { + let form_data = serde_urlencoded::to_string(form)?; + + let header_map = headers.get_or_insert_default(); + header_map.insert( + CONTENT_TYPE.to_string(), + "application/x-www-form-urlencoded".to_string(), + ); + header_map.insert(CSRF_HEADER.to_string(), self.csrf_token()?); + + let opts = ConnectOpts { + headers, + body: Some(form_data.into_bytes()), + method: Some(Method::Post), + timeout_ms: 30_000, // Actually unused, should remove at one point + }; + + self.inner.reconnect(url, opts, join_params).await?; + Ok(()) + } + + /// Set the log level for the current process. + pub fn set_log_level(&self, level: LogLevel) { + self.inner.set_log_level(level) + } + + /// Returns a handle to the current background event loop. + /// This can be used to send messages as you would + /// with a live_channel. + pub fn channel(&self) -> LiveViewClientChannel { + let inner = self.inner.create_channel(); + LiveViewClientChannel { inner } + } +} + +// Navigation-related functionality ported from LiveSocket +#[uniffi::export(async_runtime = "tokio")] +impl LiveViewClient { + /// Navigate to `url` with behavior and metadata specified in `opts`. + pub async fn navigate( + &self, + url: String, + opts: NavOptions, + ) -> Result { + self.inner.navigate(url, opts).await + } + + /// Dispose of the current channel and remount the view. Replaces the current view + /// event data with the bytes in `info` + pub async fn reload(&self, opts: NavActionOptions) -> Result { + self.inner.reload(opts).await + } + + /// Navigates back one step in the history stack. + /// This function fails if there are no items in history. + pub async fn back(&self, opts: NavActionOptions) -> Result { + self.inner.back(opts).await + } + + /// Navigates back one step in the history stack. + /// This function fails if there are no items ahead of this one in history. + pub async fn forward(&self, opts: NavActionOptions) -> Result { + self.inner.forward(opts).await + } + + /// Navigates to the entry with `id`. Retaining the state of the current history stack. + /// This function fails if the entry has been removed. + pub async fn traverse_to( + &self, + id: HistoryId, + opts: NavActionOptions, + ) -> Result { + self.inner.traverse_to(id, opts).await + } + + /// returns true if the navigation stack can support going backwards. + pub fn can_go_back(&self) -> bool { + self.inner.can_go_back() + } + + /// returns true if the navigation stack can support navigating forwards. + pub fn can_go_forward(&self) -> bool { + self.inner.can_go_forward() + } + + pub fn can_traverse_to(&self, id: HistoryId) -> bool { + self.inner.can_traverse_to(id) + } + + /// Returns a list of all History entries currently tracked by the + /// navigation context. There are no guarantees about the position of the + /// current element in this list. + pub fn get_entries(&self) -> Vec { + self.inner.get_entries() + } + + /// Returns the current history entry, Should virtually never return a nullish + /// value unless a connection error has occurred and not been properly recovered from. + pub fn current(&self) -> Option { + self.inner.current_history_entry() + } +} + +#[cfg_attr(not(target_family = "wasm"), uniffi::export)] +impl LiveViewClient { + /// Returns an ID for a given upload target. Uploads in phoenix live view + /// need an ID to indicate to the server which upload is being targeted. The meta + /// data is contained in the document - this is a convenience function for fetching it. + pub fn get_phx_upload_id(&self, phx_target_name: &str) -> Result { + self.inner.get_phx_upload_id(phx_target_name) + } + + // TODO: the live reload channel should not be a user concern. It can appear in + // an error page, as well as a successful dead render, the client config should have a callback handler + // which any given live reload channel can use + /// Returns the live reload channel if it exists, you should not need to listen for + /// `asset_change` events on this for any reason - this API is intended to be deprecated. + pub fn live_reload_channel(&self) -> Result>, LiveSocketError> { + self.inner.live_reload_channel() + } + + /// Returns the url which provided the current views dead render. + pub fn join_url(&self) -> Result { + self.inner.join_url() + } + + /// Returns the payload returned upon joining the live view channel of + /// the current view. + pub fn join_payload(&self) -> Result { + self.inner.join_payload() + } + + /// Returns the csrf token found on the current dead render. + pub fn csrf_token(&self) -> Result { + self.inner.csrf_token() + } + + /// Returns the dead render fetched before establishing the main websocket connection. + /// + /// A dead render is an html page containing meta data needed to establish a + /// websocket connection and a live view channel on the websocket. It also + /// may contain a live reload channel -- a side channel for pushing events + /// related to asset changes made by a developer which force a total reload. + pub fn dead_render(&self) -> Result, LiveSocketError> { + Ok(Arc::new(self.inner.dead_render()?.into())) + } + + /// Returns a document which contains the current state of the live view + /// in a platform specific markup. This will change under your feet as diffs are + /// applied by the server. It also may change as the live view is reloaded, in order + /// to have a pointer to the most up to date document make sure to instrument the + /// network events callback object in the [LiveViewClientBuilder] object. + pub fn document(&self) -> Result { + self.inner.document() + } + + /// returns the urls for style objects referenced by the current live view. + pub fn style_urls(&self) -> Result, LiveSocketError> { + self.inner.style_urls() + } + + /// Returns the current socket status + pub fn status(&self) -> Result { + self.inner.status() + } +} + +#[derive(uniffi::Object)] +/// A thin message sending interface that will +/// send messages through the current websocket. +pub struct LiveViewClientChannel { + inner: inner::LiveViewClientChannel, +} + +#[uniffi::export(async_runtime = "tokio")] +impl LiveViewClientChannel { + /// Sends an event to the server waiting for a reply. + /// If you do not care about the result of a call then use [LivViewClientChannel::cast] + pub async fn call( + &self, + event_name: String, + payload: Payload, + ) -> Result { + self.inner.call(event_name, payload).await + } + + /// Sends an event to the server without waiting for a reply. + pub async fn cast(&self, event_name: String, payload: Payload) { + self.inner.cast(event_name, payload).await + } +} diff --git a/crates/core/src/client/tests/lifecycle.rs b/crates/core/src/client/tests/lifecycle.rs new file mode 100644 index 000000000..8ffc561a9 --- /dev/null +++ b/crates/core/src/client/tests/lifecycle.rs @@ -0,0 +1,249 @@ +use std::sync::{Arc, Mutex}; + +use phoenix_channels_client::{Event, EventPayload, Payload, Socket, SocketStatus, JSON}; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::{json_payload, HOST}; +use crate::{ + client::{ + HandlerResponse, LiveChannelStatus, LiveViewClientConfiguration, NavEvent, NavEventHandler, + NavEventType, NavHistoryEntry, NetworkEventHandler, Platform, + }, + dom::{self}, + live_socket::LiveChannel, + LiveViewClient, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum MockMessage { + Navigation(NavEvent), + NetworkEvent(Event, Payload), + ChannelStatus(LiveChannelStatus), + SocketStatus(SocketStatus), + ViewReload { socket_is_new: bool }, +} + +#[macro_export] +macro_rules! assert_any { + ($store:expr, $predicate:expr) => { + { + let messages = $store.messages.lock().unwrap(); + assert!( + messages.iter().any($predicate), + "\nAssertion failed at {}:{}:{}\nMessage not found matching predicate.\nMessages:\n{:#?}", + file!(), + line!(), + column!(), + *messages + ); + } + }; +} + +#[derive(Default)] +pub struct MockMessageStore { + messages: Arc>>, +} + +impl MockMessageStore { + pub fn new() -> Self { + Self::default() + } + + pub fn add_message(&self, msg: MockMessage) { + let mut messages = self.messages.lock().unwrap(); + messages.push(msg); + } + + #[allow(unused)] + pub fn dump_and_panic(&self) { + let messages = self.messages.lock().unwrap(); + panic!( + "Explicitly panic in test - dumping all messages \n: {:#?}", + *messages + ); + } + + pub fn assert_contains(&self, expected: MockMessage) { + let messages = self.messages.lock().unwrap(); + assert!( + messages.contains(&expected), + "\nExpected message not found.\nExpected:\n{:#?}\n\nActual messages:\n{:#?}", + expected, + *messages + ); + } + + pub fn clear(&self) { + let mut messages = self.messages.lock().unwrap(); + messages.clear(); + } +} + +pub struct MockNavEventHandler { + message_store: Arc, +} + +impl MockNavEventHandler { + pub fn new(message_store: Arc) -> Self { + Self { message_store } + } +} + +impl NavEventHandler for MockNavEventHandler { + fn handle_event(&self, event: NavEvent) -> HandlerResponse { + self.message_store + .add_message(MockMessage::Navigation(event)); + HandlerResponse::Default + } +} + +// Mock implementation of NetworkEventHandler +#[cfg(feature = "liveview-channels")] +pub struct MockNetworkEventHandler { + message_store: Arc, +} + +#[cfg(feature = "liveview-channels")] +impl MockNetworkEventHandler { + pub fn new(message_store: Arc) -> Self { + Self { message_store } + } +} + +#[cfg(feature = "liveview-channels")] +impl NetworkEventHandler for MockNetworkEventHandler { + fn handle_event(&self, event: EventPayload) { + self.message_store + .add_message(MockMessage::NetworkEvent(event.event, event.payload)); + } + + fn handle_channel_status_change(&self, event: LiveChannelStatus) { + self.message_store + .add_message(MockMessage::ChannelStatus(event)); + } + + fn handle_socket_status_change(&self, event: SocketStatus) { + self.message_store + .add_message(MockMessage::SocketStatus(event)); + } + + fn handle_view_reloaded( + &self, + _new_document: Arc, + _new_channel: Arc, + _current_socket: Arc, + socket_is_new: bool, + ) { + self.message_store + .add_message(MockMessage::ViewReload { socket_is_new }); + } +} + +#[tokio::test] +async fn test_navigation_handler() { + let store = Arc::new(MockMessageStore::new()); + let nav_handler = Arc::new(MockNavEventHandler::new(store.clone())); + + let url = format!("http://{HOST}/nav/first_page"); + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + config.navigation_handler = Some(nav_handler); + + let client = LiveViewClient::initial_connect(config, url.clone(), Default::default()) + .await + .expect("Failed to create client"); + + let next_url = format!("http://{HOST}/nav/second_page"); + + client + .navigate(next_url.clone(), Default::default()) + .await + .expect("Failed to navigate"); + + store.assert_contains(MockMessage::Navigation(NavEvent { + event: NavEventType::Push, + same_document: false, + from: Some(NavHistoryEntry::new(url, 1, None)), + to: NavHistoryEntry::new(next_url, 2, None), + info: None, + })); +} + +#[tokio::test] +async fn test_redirect_internals() { + let store = Arc::new(MockMessageStore::new()); + let nav_handler = Arc::new(MockNavEventHandler::new(store.clone())); + let net_handler = Arc::new(MockNetworkEventHandler::new(store.clone())); + + let url = format!("http://{HOST}/hello"); + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + config.navigation_handler = Some(nav_handler); + config.network_event_handler = Some(net_handler); + + let client = LiveViewClient::initial_connect(config, url.clone(), Default::default()) + .await + .expect("Failed to create client"); + + store.clear(); + + let url = format!("http://{HOST}/push_navigate?redirect_type=live_redirect"); + let redirect_url = format!("http://{HOST}/redirect_to"); + + client + .navigate(url.clone(), Default::default()) + .await + .expect("nav failed"); + + assert_eq!(client.current_history_entry().unwrap().url, redirect_url); + + // assert that it contains at least one live redirect + assert_any!(store, |m| { + if let MockMessage::NetworkEvent(_, Payload::JSONPayload { json }) = m { + let real = json!({ + "live_redirect" : { + "to" : "/redirect_to", + "kind" : "push", + } + }); + return json == &JSON::from(real); + } + false + }); + + store.clear(); + + let url = format!("http://{HOST}/push_navigate?redirect_type=patch"); + let redirect_url = format!("http://{HOST}/push_navigate?patched=value"); + client + .navigate(url.clone(), Default::default()) + .await + .expect("nav failed"); + + // call a patch handler, patches can only happen after mount + let channel = client.create_channel(); + let payload = json_payload!({"type": "click", "event": "patchme", "value": {}}); + channel + .call("event".into(), payload) + .await + .expect("error on click"); + + // assert that the url got patched + // and that the event landed + assert_any!(store, |m| { + if let MockMessage::NetworkEvent(_, Payload::JSONPayload { json }) = m { + let real = json!({ + "kind" : "push", + "to" : "/push_navigate?patched=value" + }); + return json == &JSON::from(real); + } + false + }); + + assert_eq!(client.current_history_entry().unwrap().url, redirect_url); + + store.clear() +} diff --git a/crates/core/src/client/tests/mod.rs b/crates/core/src/client/tests/mod.rs new file mode 100644 index 000000000..709476bc7 --- /dev/null +++ b/crates/core/src/client/tests/mod.rs @@ -0,0 +1,308 @@ +mod lifecycle; +mod streaming; +mod upload; + +use crate::{ + client::{LiveViewClientConfiguration, Platform}, + dom::Document, + LiveViewClient, +}; + +macro_rules! json_payload { + ($json:tt) => {{ + let val = serde_json::json!($json); + phoenix_channels_client::Payload::JSONPayload { json: val.into() } + }}; +} + +pub(crate) use json_payload; + +macro_rules! assert_doc_eq { + ($gold:expr, $test:expr) => {{ + use pretty_assertions::assert_eq; + let gold = Document::parse($gold).expect("Gold document failed to parse"); + let test = Document::parse($test).expect("Test document failed to parse"); + assert_eq!(gold.to_string(), test.to_string()); + }}; +} + +#[cfg(target_os = "android")] +const HOST: &str = "10.0.2.2:4001"; + +#[cfg(not(target_os = "android"))] +const HOST: &str = "127.0.0.1:4001"; + +#[tokio::test] +async fn test_basic_connection() { + let url = format!("http://{HOST}/hello"); + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let initial_doc = client.document().expect("Failed to get initial page"); + + let expected = r#" + + + + Hello SwiftUI! + +"#; + + assert_doc_eq!(expected, initial_doc.to_string()); + + let url = format!("http://{HOST}/hello"); + client + .reconnect(url, Default::default(), None) + .await + .expect("reconnect failed"); + + let initial_doc = client.document().expect("Failed to get initial page"); + + assert_doc_eq!(expected, initial_doc.to_string()); + + let url = format!("http://{HOST}/nav/first_page"); + + client + .reconnect(url.clone(), Default::default(), None) + .await + .expect("reconnect failed"); + + let doc = client.document().expect("Failed to get document"); + let expected = r#" + + + + first_page + + + + NEXT + + +"#; + assert_doc_eq!(expected, doc.to_string()); + + client.disconnect().await.expect("disconnect failed"); + + client + .reconnect(url, Default::default(), None) + .await + .expect("reconnect failed"); + + assert_doc_eq!(expected, doc.to_string()); +} + +#[tokio::test] +async fn test_style_urls() { + let url = format!("http://{HOST}/hello"); + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let style_urls = client.style_urls().expect("Failed to get style URLs"); + let expected_style_urls = vec!["/assets/app.swiftui.styles".to_string()]; + + assert_eq!(style_urls, expected_style_urls); +} + +// Navigation tests +#[tokio::test] +async fn test_basic_navigation() { + let url = format!("http://{HOST}/nav/first_page"); + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let doc = client.document().expect("Failed to get document"); + let expected = r#" + + + + first_page + + + + NEXT + + +"#; + assert_doc_eq!(expected, doc.to_string()); + + // Navigate to second page + let next_url = format!("http://{HOST}/nav/second_page"); + client + .navigate(next_url, Default::default()) + .await + .expect("Failed to navigate"); + + let doc = client.document().expect("Failed to get document"); + let expected = r#" + + + + second_page + + + + NEXT + + +"#; + assert_doc_eq!(expected, doc.to_string()); +} + +#[tokio::test] +async fn test_back_and_forward_navigation() { + let url = format!("http://{HOST}/nav/first_page"); + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + // Verify initial page + let doc = client.document().expect("Failed to get document"); + let expected = r#" + + + + first_page + + + + NEXT + + +"#; + assert_doc_eq!(expected, doc.to_string()); + + let second_url = format!("http://{HOST}/nav/second_page"); + client + .navigate(second_url, Default::default()) + .await + .expect("Failed to navigate"); + + let doc = client.document().expect("Failed to get document"); + let expected = r#" + + + + second_page + + + + NEXT + + +"#; + assert_doc_eq!(expected, doc.to_string()); + + let third_url = format!("http://{HOST}/nav/third_page"); + client + .navigate(third_url, Default::default()) + .await + .expect("Failed to navigate"); + + assert!(client.can_go_back(), "Back navigation impossible"); + + client + .back(Default::default()) + .await + .expect("Failed to navigate back"); + + // Verify we're back on second page + let doc = client.document().expect("Failed to get document"); + let expected = r#" + + + + second_page + + + + NEXT + + +"#; + assert_doc_eq!(expected, doc.to_string()); + + assert!(client.can_go_forward(), "Forward navigation impossible"); + client + .forward(Default::default()) + .await + .expect("Failed to navigate forward"); + + let doc = client.document().expect("Failed to get document"); + let expected = r#" + + + + third_page + + + + NEXT + + +"#; + assert_doc_eq!(expected, doc.to_string()); +} + +#[tokio::test] +async fn thermostat_click() { + let url = format!("http://{HOST}/thermostat"); + + let config = LiveViewClientConfiguration { + format: Platform::Swiftui, + ..Default::default() + }; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let channel = client.create_channel(); + let initial_doc = client.document().expect("Failed to get initial page"); + + let expected = r#" + + + + Current temperature: 70°F + + +"#; + + assert_doc_eq!(expected, initial_doc.to_string()); + + let payload = json_payload!({"type": "click", "event": "inc_temperature", "value": {}}); + channel + .call("event".into(), payload) + .await + .expect("error on click"); + + let expected = r#" + + + + Current temperature: 71°F + + + "#; + + assert_doc_eq!(expected, initial_doc.to_string()); +} diff --git a/crates/core/src/client/tests/streaming.rs b/crates/core/src/client/tests/streaming.rs new file mode 100644 index 000000000..947120545 --- /dev/null +++ b/crates/core/src/client/tests/streaming.rs @@ -0,0 +1,77 @@ +use std::{sync::Arc, time::Duration}; + +use tokio::{ + sync::mpsc::{error, *}, + time, +}; + +use super::*; +use crate::{ + client::{ChangeType, DocumentChangeHandler}, + dom::{NodeData, NodeRef}, +}; + +const MAX_TRIES: u64 = 10; +const MS_DELAY: u64 = 1500; + +struct Inspector { + tx: UnboundedSender<(ChangeType, NodeData)>, +} + +impl Inspector { + pub fn new() -> (Self, UnboundedReceiver<(ChangeType, NodeData)>) { + let (tx, rx) = unbounded_channel(); + let out = Self { tx }; + (out, rx) + } +} + +/// An extremely simple change handler that reports diffs in order +/// over an unbounded channel +impl DocumentChangeHandler for Inspector { + fn handle_document_change( + &self, + change_type: ChangeType, + _node_ref: Arc, + node_data: NodeData, + _parent: Option>, + ) { + self.tx + .send((change_type, node_data)) + .expect("Message Never Received."); + } +} + +// Tests that streaming connects, and succeeds at parsing at least one delta. +#[tokio::test] +async fn streaming_connect() -> Result<(), String> { + let url = format!("http://{HOST}/stream"); + + let (inspector, mut rx) = Inspector::new(); + + let mut config = LiveViewClientConfiguration::default(); + config.patch_handler = Some(Arc::new(inspector)); + config.format = Platform::Swiftui; + let _client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + for _ in 0..MAX_TRIES { + match rx.try_recv() { + Ok(_) => { + return Ok(()); + } + Err(error::TryRecvError::Empty) => { + time::sleep(Duration::from_millis(MS_DELAY)).await; + } + Err(_) => { + return Err(String::from("Merging Panicked")); + } + } + } + + Err(format!( + "Exceeded {MAX_TRIES} Max tries, waited {} ms", + MAX_TRIES * MS_DELAY + )) +} diff --git a/crates/core/src/client/tests/upload.rs b/crates/core/src/client/tests/upload.rs new file mode 100644 index 000000000..1efffcfd3 --- /dev/null +++ b/crates/core/src/client/tests/upload.rs @@ -0,0 +1,206 @@ +use image::RgbaImage; +use tempfile::tempdir; + +use super::HOST; +use crate::{ + client::{LiveViewClientConfiguration, Platform}, + error::{LiveSocketError, UploadError}, + live_socket::LiveFile, + LiveViewClient, +}; + +fn get_image(imgx: u32, imgy: u32, suffix: String) -> Vec { + let mut img = RgbaImage::new(imgx, imgy); + let tile = image::load_from_memory_with_format( + include_bytes!("../../../tests/support/tinycross.png"), + image::ImageFormat::Png, + ) + .expect("Failed to load example image"); + + let tmp_dir = tempdir().expect("Failed to get tempdir"); + let file_path = tmp_dir.path().join(format!("image-{imgx}-{imgy}.{suffix}")); + + image::imageops::tile(&mut img, &tile); + img.save(file_path.clone()).unwrap(); + + std::fs::read(file_path).expect("Failed to get image") +} + +#[tokio::test] +async fn test_single_chunk_file_upload() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let url = format!("http://{HOST}/upload"); + let image_bytes = get_image(100, 100, "png".to_string()); + + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let phx_upload_id = client + .get_phx_upload_id("avatar") + .expect("No ID for avatar"); + + let file = LiveFile::new( + image_bytes, + "image/png".to_string(), + "avatar".to_string(), + "tile.png".to_string(), + phx_upload_id, + ); + + client + .upload_file(file.into()) + .await + .expect("Failed to upload file"); +} + +#[tokio::test] +async fn test_multi_chunk_text_upload() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let url = format!("http://{HOST}/upload"); + let text_bytes = Vec::from_iter(std::iter::repeat_n(b'a', 48_000)); + + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let phx_upload_id = client + .get_phx_upload_id("sample_text") + .expect("No ID for sample_text"); + + let file = LiveFile::new( + text_bytes, + "text/plain".to_string(), + "sample_text".to_string(), + "lots_of_as.txt".to_string(), + phx_upload_id, + ); + + client + .upload_file(file.into()) + .await + .expect("Failed to upload file"); +} + +#[tokio::test] +async fn test_multi_chunk_file_upload() { + let url = format!("http://{HOST}/upload"); + let image_bytes = get_image(2000, 2000, "png".to_string()); + + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let phx_upload_id = client + .get_phx_upload_id("avatar") + .expect("No ID for avatar"); + + let file = LiveFile::new( + image_bytes, + "image/png".to_string(), + "avatar".to_string(), + "tile.png".to_string(), + phx_upload_id, + ); + + client + .upload_file(file.into()) + .await + .expect("Failed to upload file"); +} + +#[tokio::test] +async fn test_file_too_large_error() { + let _ = env_logger::builder() + .parse_default_env() + .filter_level(log::LevelFilter::Debug) + .is_test(true) + .try_init(); + + let url = format!("http://{HOST}/upload"); + let image_bytes = get_image(2000, 2000, "tiff".to_string()); + + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let phx_upload_id = client + .get_phx_upload_id("avatar") + .expect("No ID for avatar"); + + let file = LiveFile::new( + image_bytes, + "image/png".to_string(), + "avatar".to_string(), + "tile.png".to_string(), + phx_upload_id, + ); + + let error = client.upload_file(file.into()).await.err().unwrap(); + + match error { + LiveSocketError::Upload { + error: UploadError::FileTooLarge, + } => {} + e => panic!("Expected FileTooLarge error, got: {e:?}"), + } +} + +#[tokio::test] +async fn test_incorrect_file_type_error() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let url = format!("http://{HOST}/upload"); + let image_bytes = get_image(100, 100, "png".to_string()); + + let mut config = LiveViewClientConfiguration::default(); + config.format = Platform::Swiftui; + + let client = LiveViewClient::initial_connect(config, url, Default::default()) + .await + .expect("Failed to create client"); + + let phx_upload_id = client + .get_phx_upload_id("avatar") + .expect("No ID for avatar"); + + let file = LiveFile::new( + image_bytes, + "avatar".to_string(), + "image/png".to_string(), + "tile.png".to_string(), + phx_upload_id, + ); + + let error = client.upload_file(file.into()).await.err().unwrap(); + + match error { + LiveSocketError::Upload { + error: UploadError::FileNotAccepted, + } => {} + e => panic!("Expected FileNotAccepted error, got: {e:?}"), + } +} diff --git a/crates/core/src/diff/fragment/error.rs b/crates/core/src/diff/fragment/error.rs index e88cc7cc5..e279c048b 100644 --- a/crates/core/src/diff/fragment/error.rs +++ b/crates/core/src/diff/fragment/error.rs @@ -43,7 +43,7 @@ pub enum RenderError { #[error("Serde Error {0}")] SerdeError(#[from] serde_json::Error), #[error("Parse Error {0}")] - ParseError(#[from] crate::parser::ParseError), + ParseError(#[from] crate::dom::ParseError), } #[derive(Debug, thiserror::Error, uniffi::Error)] diff --git a/crates/core/src/diff/fragment/tests/mod.rs b/crates/core/src/diff/fragment/tests/mod.rs index d3c0efe3d..28d0a9359 100644 --- a/crates/core/src/diff/fragment/tests/mod.rs +++ b/crates/core/src/diff/fragment/tests/mod.rs @@ -57,6 +57,7 @@ fn stream_parsing() { #[macro_export] macro_rules! json_struct { ($($token:tt)*) => {{ + use serde_json::json; serde_json::from_value(json!($($token)*)) .expect("Error deserializing JSON") }}; diff --git a/crates/core/src/dom/ffi.rs b/crates/core/src/dom/ffi.rs index 8743cbea1..e7ad50a9d 100644 --- a/crates/core/src/dom/ffi.rs +++ b/crates/core/src/dom/ffi.rs @@ -3,16 +3,15 @@ use std::{ sync::{Arc, Mutex}, }; -use super::ChangeType; pub use super::{ attribute::Attribute, node::{Node, NodeData, NodeRef}, printer::PrintOptions, - DocumentChangeHandler, }; use crate::{ + callbacks::*, diff::{fragment::RenderError, PatchResult}, - parser::ParseError, + dom::parser::ParseError, }; #[derive(Clone, uniffi::Object)] @@ -34,6 +33,66 @@ impl Document { pub(crate) fn inner(&self) -> Arc> { self.inner.clone() } + + pub fn arc_set_event_handler(&self, handler: Arc) { + self.inner.lock().expect("lock poisoned!").event_callback = Some(handler); + } + + #[cfg(feature = "liveview-channels")] + pub fn merge_deserialized_fragment_json( + &self, + json: phoenix_channels_client::JSON, + ) -> Result<(), RenderError> { + let results = self + .inner + .lock() + .expect("lock poisoned!") + .merge_fragment_json(json.into())?; + + let Some(handler) = self + .inner + .lock() + .expect("lock poisoned") + .event_callback + .clone() + else { + return Ok(()); + }; + + for patch in results.into_iter() { + match patch { + PatchResult::Add { node, parent, data } => { + handler.handle_document_change( + ChangeType::Add, + node.into(), + data, + Some(parent.into()), + ); + } + PatchResult::Remove { node, parent, data } => { + handler.handle_document_change( + ChangeType::Remove, + node.into(), + data, + Some(parent.into()), + ); + } + PatchResult::Change { node, data } => { + handler.handle_document_change(ChangeType::Change, node.into(), data, None); + } + PatchResult::Replace { node, parent, data } => { + handler.handle_document_change( + ChangeType::Replace, + node.into(), + data, + Some(parent.into()), + ); + } + } + } + + Ok(()) + } } #[uniffi::export] diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index 4105a3db7..31fb2e864 100644 --- a/crates/core/src/dom/mod.rs +++ b/crates/core/src/dom/mod.rs @@ -1,6 +1,7 @@ mod attribute; pub mod ffi; mod node; +mod parser; mod printer; mod select; @@ -23,15 +24,16 @@ use self::printer::Printer; pub use self::{ attribute::{Attribute, AttributeName, AttributeValue}, node::{Element, ElementName, NodeData, NodeRef}, + parser::ParseError, printer::PrintOptions, select::{SelectionIter, Selector}, }; use crate::{ + callbacks::DocumentChangeHandler, diff::{ fragment::{FragmentMerge, RenderError, Root, RootDiff}, PatchResult, }, - parser, }; /// A `Document` represents a virtual DOM, and supports common operations typically performed against them. @@ -529,7 +531,7 @@ impl Document { let fragment: RootDiff = serde_json::from_str(&input).map_err(RenderError::from)?; let root: Root = fragment.try_into()?; let rendered: String = root.clone().try_into()?; - let mut document = crate::parser::parse(&rendered)?; + let mut document = crate::dom::parser::parse(&rendered)?; document.fragment_template = Some(root); Ok(document) } @@ -615,73 +617,6 @@ impl Document { } } -#[repr(C)] -#[derive(Copy, Clone, uniffi::Enum)] -pub enum ChangeType { - Change = 0, - Add = 1, - Remove = 2, - Replace = 3, -} - -#[derive(Copy, Clone, uniffi::Enum)] -pub enum EventType { - Changed, // { change: ChangeType }, -} - -#[derive(Clone, uniffi::Enum)] -pub enum ControlFlow { - ExitOk, - ExitErr(String), - ContinueListening, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, uniffi::Enum)] -pub enum LiveChannelStatus { - /// [Channel] is waiting for the [Socket](crate::Socket) to - /// [Socket::connect](crate::Socket::connect) or automatically reconnect. - WaitingForSocketToConnect, - /// [Socket::status](crate::Socket::status) is - /// [SocketStatus::Connected](crate::SocketStatus::Connected) and [Channel] is waiting for - /// [Channel::join] to be called. - WaitingToJoin, - /// [Channel::join] was called and awaiting response from server. - Joining, - /// [Channel::join] was called previously, but the [Socket](crate::Socket) was disconnected and - /// reconnected. - WaitingToRejoin, - /// [Channel::join] was called and the server responded that the [Channel::topic] was joined - /// using [Channel::payload]. - Joined, - /// [Channel::leave] was called and awaiting response from server. - Leaving, - /// [Channel::leave] was called and the server responded that the [Channel::topic] was left. - Left, - /// [Channel::shutdown] was called, but the async task hasn't exited yet. - ShuttingDown, - /// The async task has exited. - ShutDown, -} - -/// Implements the change handling logic for inbound virtual dom -/// changes. Your logic for handling document patches should go here. -#[uniffi::export(callback_interface)] -pub trait DocumentChangeHandler: Send + Sync { - /// This callback should implement your dom manipulation logic - /// after receiving patches from LVN. - fn handle_document_change( - &self, - change_type: ChangeType, - node_ref: Arc, - node_data: NodeData, - parent: Option>, - ); - - /// Called when the channel status changes. Background operations like [LiveChannel::merge_diffs] - /// will exit with a status based on the return [ControlFlow] of this callback. - fn handle_channel_status(&self, channel_status: LiveChannelStatus) -> ControlFlow; -} - /// This trait is used to provide functionality common to construction/mutating documents pub trait DocumentBuilder { fn document(&self) -> &Document; diff --git a/crates/core/src/parser/mod.rs b/crates/core/src/dom/parser.rs similarity index 100% rename from crates/core/src/parser/mod.rs rename to crates/core/src/dom/parser.rs diff --git a/crates/core/src/error/mod.rs b/crates/core/src/error/mod.rs new file mode 100644 index 000000000..ee24fd4c9 --- /dev/null +++ b/crates/core/src/error/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "liveview-channels")] +mod socket; + +#[cfg(feature = "liveview-channels")] +pub use socket::*; diff --git a/crates/core/src/live_socket/error.rs b/crates/core/src/error/socket.rs similarity index 93% rename from crates/core/src/live_socket/error.rs rename to crates/core/src/error/socket.rs index 61435dfe7..997be80b0 100644 --- a/crates/core/src/live_socket/error.rs +++ b/crates/core/src/error/socket.rs @@ -6,13 +6,19 @@ use phoenix_channels_client::{ use crate::{ diff::fragment::{MergeError, RenderError}, - parser::ParseError, + dom::ParseError, }; #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum LiveSocketError { + #[error("call to `call` failed: {error}.")] + Call { error: String }, + #[error("call to `cast` failed: {error}.")] + Cast { error: String }, #[error("Internal Socket Locks would block.")] WouldLock, + #[error("Form Data Failed to Serialize. {error}")] + FormData { error: String }, #[error("Internal Socket Locks poisoned.")] LockPoisoned, #[error("Error disconnecting")] @@ -135,6 +141,14 @@ impl From> for LiveSocketError { } } +impl From for LiveSocketError { + fn from(e: serde_urlencoded::ser::Error) -> Self { + Self::FormData { + error: format!("{e}"), + } + } +} + impl From for LiveSocketError { fn from(value: PhoenixError) -> Self { match value { @@ -217,6 +231,7 @@ impl From for LiveSocketError { Self::from(PhoenixError::from(value)) } } + impl From for LiveSocketError { fn from(value: reqwest::Error) -> Self { Self::Request { @@ -224,6 +239,7 @@ impl From for LiveSocketError { } } } + impl From for LiveSocketError { fn from(value: serde_json::Error) -> Self { Self::Serde { diff --git a/crates/core/src/interner/mod.rs b/crates/core/src/interner/mod.rs index 5ad40c325..1e1712d58 100644 --- a/crates/core/src/interner/mod.rs +++ b/crates/core/src/interner/mod.rs @@ -14,6 +14,9 @@ use fxhash::FxHashMap; #[rustfmt::skip] #[allow(nonstandard_style, non_upper_case_globals)] pub mod symbols { + // TODO: this is overoptimized, we use NONE of the interned strings here as parsing HTML is not + // not a goal of the library. we should remove this at one point. + // // During the build step, `build.rs` will output the generated atoms to `OUT_DIR` to avoid // adding it to the source directory, so we just directly include the generated code here. include!(concat!(env!("OUT_DIR"), "/strings.rs")); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8c57d7b11..98fb4892a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,17 @@ +#[cfg(feature = "liveview-channels")] +pub mod client; + +#[cfg(feature = "liveview-channels")] +pub use client::inner::LiveViewClientInner as LiveViewClient; +#[cfg(feature = "liveview-channels")] +pub use client::LiveViewClientBuilder; + +mod callbacks; pub mod diff; pub mod dom; -pub mod parser; +mod protocol; + +mod error; #[cfg(feature = "liveview-channels")] pub mod live_socket; diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/live_socket/channel.rs index 4aa3da05f..f3257f041 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/live_socket/channel.rs @@ -4,14 +4,12 @@ use futures::{future::FutureExt, pin_mut, select}; use log::{debug, error}; use phoenix_channels_client::{Channel, Event, Number, Payload, Socket, Topic, JSON}; -use super::{LiveSocketError, UploadConfig, UploadError}; +use super::UploadConfig; use crate::{ + callbacks::*, diff::fragment::{Root, RootDiff}, - dom::{ - ffi::{Document as FFiDocument, DocumentChangeHandler}, - AttributeName, AttributeValue, Document, LiveChannelStatus, Selector, - }, - parser::parse, + dom::{ffi::Document as FFiDocument, AttributeName, AttributeValue, Document, Selector}, + error::*, }; #[derive(uniffi::Object)] @@ -26,6 +24,7 @@ pub struct LiveChannel { #[derive(uniffi::Object)] pub struct LiveFile { + // TODO: this really ought to be a data stream callback. contents: Vec, mime_type: String, name: String, @@ -96,7 +95,7 @@ impl LiveChannel { let root: RootDiff = serde_json::from_str(rendered.as_str())?; let root: Root = root.try_into()?; let root: String = root.try_into()?; - let document = parse(&root)?; + let document = Document::parse(&root)?; Some(document) } else { @@ -199,26 +198,10 @@ impl LiveChannel { }; } new_status = status => { - - let handler = document - .inner() - .lock() - .expect("lock poisoned") - .event_callback - .clone(); - - if let Some(handler) = handler { - match handler.handle_channel_status(new_status?.into()) { - crate::dom::ControlFlow::ExitOk => return Ok(()), - crate::dom::ControlFlow::ExitErr(error) => return Err(LiveSocketError::ChannelStatusUserError { error }), - crate::dom::ControlFlow::ContinueListening => {}, - }; - } else { - match new_status? { - phoenix_channels_client::ChannelStatus::Left => return Ok(()), - phoenix_channels_client::ChannelStatus::ShutDown => return Ok(()), - _ => {}, - } + match new_status? { + phoenix_channels_client::ChannelStatus::Left => return Ok(()), + phoenix_channels_client::ChannelStatus::ShutDown => return Ok(()), + _ => {}, } } }; diff --git a/crates/core/src/live_socket/mod.rs b/crates/core/src/live_socket/mod.rs index 25b66e22f..05afd267c 100644 --- a/crates/core/src/live_socket/mod.rs +++ b/crates/core/src/live_socket/mod.rs @@ -1,14 +1,12 @@ mod channel; -mod error; -mod navigation; +pub mod navigation; mod socket; #[cfg(test)] mod tests; -pub use channel::LiveChannel; -pub use error::{LiveSocketError, UploadError}; -pub use socket::LiveSocket; +pub use channel::{LiveChannel, LiveFile}; +pub use socket::{ConnectOpts, LiveSocket, Method, SessionData}; pub struct UploadConfig { chunk_size: u64, diff --git a/crates/core/src/live_socket/navigation/ffi.rs b/crates/core/src/live_socket/navigation/ffi.rs index 35c9f8aeb..838f08b78 100644 --- a/crates/core/src/live_socket/navigation/ffi.rs +++ b/crates/core/src/live_socket/navigation/ffi.rs @@ -4,66 +4,13 @@ use std::collections::HashMap; use phoenix_channels_client::{Payload, Socket, JSON}; -use reqwest::Url; +#[cfg(not(test))] +use reqwest::cookie::Jar; +use reqwest::{redirect::Policy, Url}; -pub type HistoryId = u64; -const RETRY_REASONS: &[&str] = &["stale", "unauthorized"]; - -#[uniffi::export(callback_interface)] -pub trait NavEventHandler: Send + Sync { - /// This callback instruments events that occur when your user navigates to a - /// new view. You can add serialized metadata to these events as a byte buffer - /// through the [NavOptions] object. - fn handle_event(&self, event: NavEvent) -> HandlerResponse; -} - -/// User emitted response from [NavEventHandler::handle_event]. -/// Determines whether or not the default navigation action is taken. -#[derive(uniffi::Enum, Clone, Debug, PartialEq, Default)] -pub enum HandlerResponse { - #[default] - /// Return this to proceed as normal. - Default, - /// Return this to cancel the navigation before it occurs. - PreventDefault, -} - -#[derive(uniffi::Enum, Clone, Debug, PartialEq)] -pub enum NavEventType { - /// Pushing a new event onto the history stack - Push, - /// Replacing the most recent event on the history stack - Replace, - /// Reloading the view in place - Reload, - /// Skipping multiple items on the history stack, leaving them in tact. - Traverse, -} - -#[derive(uniffi::Record, Clone, Debug, PartialEq)] -pub struct NavHistoryEntry { - /// The target url. - pub url: String, - /// Unique id for this piece of nav entry state. - pub id: HistoryId, - /// state passed in by the user, to be passed in to the navigation event callback. - pub state: Option>, -} +use crate::callbacks::*; -/// An event emitted when the user navigates between views. -#[derive(uniffi::Record, Clone, Debug, PartialEq)] -pub struct NavEvent { - /// The type of event being emitted. - pub event: NavEventType, - /// True if from and to point to the same path. - pub same_document: bool, - /// The previous location of the page, if there was one. - pub from: Option, - /// Destination URL. - pub to: NavHistoryEntry, - /// Additional user provided metadata handed to the event handler. - pub info: Option>, -} +const RETRY_REASONS: &[&str] = &["stale", "unauthorized"]; /// An action taken with respect to the history stack /// when [NavCtx::navigate] is executed. defaults to @@ -80,6 +27,9 @@ pub enum NavAction { /// Options for calls to [NavCtx::navigate] #[derive(Default, uniffi::Record)] pub struct NavOptions { + /// Additional params to be passed upon joining the liveview channel. + #[uniffi(default = None)] + pub join_params: Option>, /// see [NavAction], defaults to [NavAction::Push]. #[uniffi(default = None)] pub action: Option, @@ -92,6 +42,16 @@ pub struct NavOptions { pub state: Option>, } +#[derive(Default, uniffi::Record)] +pub struct NavActionOptions { + /// Additional params to be passed upon joining the liveview channel. + #[uniffi(default = None)] + pub join_params: Option>, + /// Ephemeral extra information to be pushed to the even handler. + #[uniffi(default = None)] + pub extra_event_info: Option>, +} + impl NavEvent { pub fn new( event: NavEventType, @@ -116,8 +76,15 @@ impl NavEvent { } } -use super::{super::error::LiveSocketError, LiveSocket, NavCtx}; -use crate::live_socket::{socket::SessionData, LiveChannel}; +use super::{LiveSocket, NavCtx}; +#[cfg(not(test))] +use crate::live_socket::socket::COOKIE_JAR; +#[cfg(test)] +use crate::live_socket::socket::TEST_COOKIE_JAR; +use crate::{ + error::LiveSocketError, + live_socket::{socket::SessionData, LiveChannel}, +}; impl LiveSocket { /// Tries to navigate to the current item in the NavCtx. @@ -162,7 +129,19 @@ impl LiveSocket { let format = self.session_data.try_lock()?.format.clone(); let options = self.session_data.try_lock()?.connect_opts.clone(); - let session_data = SessionData::request(&url, &format, options).await?; + //TODO: punt the to an argument. move this on to the LiveViewClient + #[cfg(not(test))] + let jar = COOKIE_JAR.get_or_init(|| Jar::default().into()); + + #[cfg(test)] + let jar = TEST_COOKIE_JAR.with(|inner| inner.clone()); + + let client = reqwest::Client::builder() + .cookie_provider(jar.clone()) + .redirect(Policy::none()) + .build()?; + + let session_data = SessionData::request(&url, &format, options, client).await?; let websocket_url = session_data.get_live_socket_url()?; let socket = Socket::spawn(websocket_url, Some(session_data.cookies.clone())).await?; diff --git a/crates/core/src/live_socket/navigation/mod.rs b/crates/core/src/live_socket/navigation/mod.rs index 9aeda45be..202aa1fb9 100644 --- a/crates/core/src/live_socket/navigation/mod.rs +++ b/crates/core/src/live_socket/navigation/mod.rs @@ -1,9 +1,12 @@ mod ffi; -use super::socket::LiveSocket; +use std::sync::Arc; + pub use ffi::*; use reqwest::Url; -use std::sync::Arc; + +use super::socket::LiveSocket; +use crate::callbacks::*; #[derive(Clone, Default)] struct HandlerInternal(pub Option>); @@ -33,16 +36,6 @@ pub struct NavCtx { navigation_event_handler: HandlerInternal, } -impl NavHistoryEntry { - pub fn new(url: Url, id: HistoryId, state: Option>) -> Self { - Self { - url: url.to_string(), - id, - state, - } - } -} - impl NavCtx { /// Navigate to `url` with behavior and metadata specified in `opts`. /// Returns the current history ID if changed diff --git a/crates/core/src/live_socket/socket.rs b/crates/core/src/live_socket/socket.rs index cf3c7cd0b..07d832483 100644 --- a/crates/core/src/live_socket/socket.rs +++ b/crates/core/src/live_socket/socket.rs @@ -5,21 +5,22 @@ use std::{ time::Duration, }; -use log::debug; -use phoenix_channels_client::{url::Url, Number, Payload, Socket, SocketStatus, Topic, JSON}; +use log::{debug, trace}; +use phoenix_channels_client::{url::Url, Payload, Socket, SocketStatus, Topic, JSON}; use reqwest::{ cookie::{CookieStore, Jar}, header::{HeaderMap, LOCATION, SET_COOKIE}, redirect::Policy, - Method as ReqMethod, + Client, Method as ReqMethod, }; +use serde::Serialize; use super::navigation::{NavCtx, NavOptions}; -pub use super::{LiveChannel, LiveSocketError}; +pub use super::LiveChannel; use crate::{ diff::fragment::{Root, RootDiff}, dom::{ffi::Document as FFiDocument, AttributeName, Document, ElementName, Selector}, - parser::parse, + error::LiveSocketError, }; #[macro_export] @@ -35,13 +36,13 @@ macro_rules! lock { #[cfg(not(test))] use std::sync::OnceLock; #[cfg(not(test))] -static COOKIE_JAR: OnceLock> = OnceLock::new(); +pub static COOKIE_JAR: OnceLock> = OnceLock::new(); // Each test runs in a separate thread and should make requests // as if it is an isolated session. #[cfg(test)] thread_local! { - static TEST_COOKIE_JAR: Arc = Arc::default(); + pub static TEST_COOKIE_JAR: Arc = Arc::default(); } const MAX_REDIRECTS: usize = 10; @@ -90,7 +91,7 @@ pub struct ConnectOpts { #[uniffi(default = None)] pub headers: Option>, #[uniffi(default = None)] - pub body: Option, + pub body: Option>, #[uniffi(default = None)] pub method: Option, #[uniffi(default = 30_000)] @@ -111,6 +112,7 @@ impl Default for ConnectOpts { /// Static information ascertained from the dead render when connecting. #[derive(Clone, Debug)] pub struct SessionData { + /// reply headers pub join_headers: HashMap>, pub connect_opts: ConnectOpts, /// Cross site request forgery, security token, sent with dead render. @@ -131,11 +133,31 @@ pub struct SessionData { pub cookies: Vec, } +//TODO: Move this into the protocol module when it exists +/// The expected structure of a json payload send upon joining a liveview channel +#[derive(Serialize)] +struct JoinRequestPayload { + #[serde(rename = "static")] + static_token: String, + session: String, + #[serde(flatten)] + url_or_redirect: UrlOrRedirect, + params: HashMap, +} + +#[derive(Serialize)] +#[serde(untagged)] +enum UrlOrRedirect { + Url { url: String }, + Redirect { redirect: String }, +} + impl SessionData { pub async fn request( url: &Url, format: &String, connect_opts: ConnectOpts, + client: Client, ) -> Result { // NEED: // these from inside data-phx-main @@ -146,9 +168,12 @@ impl SessionData { // Top level: // csrf-token // "iframe[src=\"/phoenix/live_reload/frame\"]" + let (dead_render, cookies, url, header_map) = - LiveSocket::get_dead_render(url, format, &connect_opts).await?; + LiveSocket::get_dead_render(url, format, &connect_opts, client).await?; + //TODO: remove cookies, pull it from the cookie client cookie store. + log::trace!("dead render retrieved:\n {dead_render}"); let csrf_token = dead_render .get_csrf_token() .ok_or(LiveSocketError::CSRFTokenMissing)?; @@ -164,7 +189,7 @@ impl SessionData { })) .last(); - debug!("MAIN DIV: {main_div_attributes:?}"); + trace!("main div attributes: {main_div_attributes:?}"); let main_div_attributes = dead_render .select(Selector::Attribute(AttributeName { @@ -188,7 +213,7 @@ impl SessionData { let phx_id = phx_id.ok_or(LiveSocketError::PhoenixIDMissing)?; let phx_static = phx_static.ok_or(LiveSocketError::PhoenixStaticMissing)?; let phx_session = phx_session.ok_or(LiveSocketError::PhoenixSessionMissing)?; - debug!("phx_id = {phx_id:?}, session = {phx_session:?}, static = {phx_static:?}"); + trace!("phx_id = {phx_id:?}, session = {phx_session:?}, static = {phx_static:?}"); // A Style looks like: //