diff --git a/crates/core/examples/streaming.rs b/crates/core/examples/streaming.rs deleted file mode 100644 index 43b0fc01..00000000 --- a/crates/core/examples/streaming.rs +++ /dev/null @@ -1,26 +0,0 @@ -use liveview_native_core::live_socket::LiveSocket; - -#[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::main] -async fn main() { - let _ = env_logger::builder().parse_default_env().try_init(); - - let url = format!("http://{HOST}/stream"); - - let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default()) - .await - .expect("Failed to get liveview socket"); - let live_channel = live_socket - .join_liveview_channel(None, None) - .await - .expect("Failed to join the liveview channel"); - live_channel - .merge_diffs() - .await - .expect("Failed to merge diffs"); -} 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 cb0ec2c2..eaf8d9fe 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 @@ -5,47 +5,57 @@ import kotlinx.coroutines.* import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test +import org.phoenixframework.liveviewnative.core.ClientConnectOpts import org.phoenixframework.liveviewnative.core.ChangeType -import org.phoenixframework.liveviewnative.core.ConnectOpts import org.phoenixframework.liveviewnative.core.Document import org.phoenixframework.liveviewnative.core.DocumentChangeHandler import org.phoenixframework.liveviewnative.core.LiveFile -import org.phoenixframework.liveviewnative.core.LiveSocket +import org.phoenixframework.liveviewnative.core.LiveViewClient +import org.phoenixframework.liveviewnative.core.LiveViewClientBuilder import org.phoenixframework.liveviewnative.core.NavOptions import org.phoenixframework.liveviewnative.core.NodeData import org.phoenixframework.liveviewnative.core.NodeRef +import org.phoenixframework.liveviewnative.core.Platform class SocketTest { @Test fun simple_connect() = runTest { - var live_socket = LiveSocket.connect("http://127.0.0.1:4001/upload", "jetpack", null) - var live_channel = live_socket.joinLiveviewChannel(null, null) + var opts = ClientConnectOpts() + var builder = LiveViewClientBuilder() + + builder.setFormat(Platform.Jetpack) + + var client = builder.connect("http://127.0.0.1:4001/upload", opts) + // This is a PNG located at crates/core/tests/support/tinycross.png var base64TileImg = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEdFQog0ycfAgAAAIJJREFUOMulU0EOwCAIK2T/f/LYwWAAgZGtJzS1BbVEuEVAAACCQOsKlkOrEicwgeVz5tC5R1yrDdnKuo6j6J5ydgd+npOUHfaGEJkQq+6cQNVqP1oQiCJxvAjGT3Dn3l1sKpAdfhPhqXP5xDYLXz7SkYUuUNnrcBWULkRlFqZxtvwH8zGCEN6LErUAAAAASUVORK5CYII=" val contents = Base64.getDecoder().decode(base64TileImg) - val phx_upload_id = live_channel.getPhxUploadId("avatar") + val phx_upload_id = client.getPhxUploadId("avatar") var live_file = LiveFile(contents, "image/png", "avatar", "foobar.png", phx_upload_id) - live_channel.uploadFile(live_file) + client.uploadFiles(listOf(live_file)) } } class SocketTestOpts { @Test fun connect_with_opts() = runTest { - var opts = ConnectOpts() - var live_socket = LiveSocket.connect("http://127.0.0.1:4001/upload", "jetpack", opts) - var live_channel = live_socket.joinLiveviewChannel(null, null) + var opts = ClientConnectOpts() + var builder = LiveViewClientBuilder() + + builder.setFormat(Platform.Jetpack) + + var client = builder.connect("http://127.0.0.1:4001/upload", opts) // This is a PNG located at crates/core/tests/support/tinycross.png var base64TileImg = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEdFQog0ycfAgAAAIJJREFUOMulU0EOwCAIK2T/f/LYwWAAgZGtJzS1BbVEuEVAAACCQOsKlkOrEicwgeVz5tC5R1yrDdnKuo6j6J5ydgd+npOUHfaGEJkQq+6cQNVqP1oQiCJxvAjGT3Dn3l1sKpAdfhPhqXP5xDYLXz7SkYUuUNnrcBWULkRlFqZxtvwH8zGCEN6LErUAAAAASUVORK5CYII=" val contents = Base64.getDecoder().decode(base64TileImg) - val phx_upload_id = live_channel.getPhxUploadId("avatar") + val phx_upload_id = client.getPhxUploadId("avatar") var live_file = LiveFile(contents, "image/png", "avatar", "foobar.png", phx_upload_id) - live_channel.uploadFile(live_file) + client.uploadFiles(listOf(live_file)) } } @@ -252,9 +262,14 @@ class DocumentTest { val host = "127.0.0.1:4001" val url = "http://$host/nav/first_page" - val liveSocket = LiveSocket.connect(url, "jetpack", null) - val liveChannel = liveSocket.joinLiveviewChannel(null, null) - val doc = liveChannel.document() + var opts = ClientConnectOpts() + var builder = LiveViewClientBuilder() + + builder.setFormat(Platform.Jetpack) + + var client = builder.connect(url, opts) + + val doc = client.document() val expectedFirstDoc = """ @@ -272,9 +287,9 @@ class DocumentTest { assertEquals(exp.render(), doc.render()) val secondUrl = "http://$host/nav/second_page" - val secondChannel = liveSocket.navigate(secondUrl, null, NavOptions()) + client.navigate(secondUrl, NavOptions()) - val secondDoc = secondChannel.document() + val secondDoc = client.document() val expectedSecondDoc = """ 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 a226c0a9..e2802b47 100644 --- a/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift +++ b/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift @@ -9,7 +9,6 @@ extension LiveViewNativeCore.Payload: @unchecked Sendable {} extension LiveViewNativeCore.EventPayload: @unchecked Sendable {} extension LiveViewNativeCore.LiveChannel: @unchecked Sendable {} -extension LiveViewNativeCore.LiveSocket: @unchecked Sendable {} extension LiveViewNativeCore.Events: @unchecked Sendable {} extension LiveViewNativeCore.ChannelStatuses: @unchecked Sendable {} 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 d4fed0e4..e7a82be3 100644 --- a/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift +++ b/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift @@ -11,32 +11,27 @@ let timeout = TimeInterval(30.0) let connect_url = "http://127.0.0.1:4001/hello" final class LiveViewNativeCoreSocketTests: XCTestCase { func testConnect() async throws { - let live_socket = try await LiveSocket(connect_url, "swiftui", .none) - let _ = try await live_socket.joinLiveviewChannel(.none, .none) + let builder = LiveViewClientBuilder() + let client = try await builder.connect(connect_url, ClientConnectOpts()) } func testConnectWithOpts() async throws { let headers = [String: String]() - let options = ConnectOpts(headers: headers) - let live_socket = try await LiveSocket(connect_url, "swiftui", options) - let _ = try await live_socket.joinLiveviewChannel(.none, .none) + let options = ClientConnectOpts(headers: headers) + let builder = LiveViewClientBuilder() + let client = try await builder.connect(connect_url, options) } func testStatus() async throws { - let live_socket = try await LiveSocket(connect_url, "swiftui", .none) - let _ = try await live_socket.joinLiveviewChannel(.none, .none) - let socket = live_socket.socket() + let builder = LiveViewClientBuilder() + let client = try await builder.connect(connect_url, ClientConnectOpts()) - var status = socket.status() + var status = try client.status() XCTAssertEqual(status, .connected) - try await socket.disconnect() - status = socket.status() + try await client.disconnect() + status = try client.status() XCTAssertEqual(status, .disconnected) - - try await socket.shutdown() - status = socket.status() - XCTAssertEqual(status, .shutDown) } func testBasicConnection() async throws { @@ -111,64 +106,14 @@ let base64TileImg = let upload_url = "http://127.0.0.1:4001/upload" final class LiveViewNativeCoreUploadTests: XCTestCase { func testUpload() async throws { - let live_socket = try await LiveSocket(upload_url, "swiftui", .none) - let live_channel = try await live_socket.joinLiveviewChannel(.none, .none) + // Using the new LiveViewClient API + let builder = LiveViewClientBuilder() + let client = try await builder.connect(upload_url, ClientConnectOpts()) let image: Data! = Data(base64Encoded: base64TileImg) - let phx_id: String! = try live_channel.getPhxUploadId("avatar") + let phx_id: String! = try client.getPhxUploadId("avatar") let live_file = LiveFile(image, "image/png", "avatar", "foobar.png", phx_id) - try await live_channel.uploadFile(live_file) + try await client.uploadFiles([live_file]) } } - -// Test basic navigation flow with LiveSocket -func testBasicNavFlow() async throws { - let url = "http://127.0.0.1:4001/nav/first_page" - let secondUrl = "http://127.0.0.1:4001/nav/second_page" - - let liveSocket = try await LiveSocket(url, "swiftui", .none) - let liveChannel = try await liveSocket.joinLiveviewChannel(.none, .none) - - let doc = liveChannel.document() - - let expectedFirstDoc = """ - - - - first_page - - - - NEXT - - - - """ - - let exp = try Document.parse(expectedFirstDoc) - - XCTAssertEqual(doc.render(), exp.render()) - - let secondChannel = try await liveSocket.navigate(secondUrl, .none, NavOptions()) - - let secondDoc = secondChannel.document() - - let expectedSecondDoc = """ - - - - second_page - - - - NEXT - - - - """ - - let secondExp = try Document.parse(expectedSecondDoc) - - XCTAssertEqual(secondDoc.render(), secondExp.render()) -} diff --git a/crates/core/src/callbacks.rs b/crates/core/src/callbacks.rs index d21fdc04..383e6fed 100644 --- a/crates/core/src/callbacks.rs +++ b/crates/core/src/callbacks.rs @@ -5,7 +5,7 @@ use phoenix_channels_client::{Socket, SocketStatus}; use crate::dom::{NodeData, NodeRef}; #[cfg(feature = "liveview-channels")] -use crate::{dom::ffi::Document, live_socket::LiveChannel}; +use crate::{client::LiveChannel, dom::ffi::Document}; /// Provides secure persistent storage for session data like cookies. /// Implementations should handle platform-specific storage (e.g. NSUserDefaults on iOS) diff --git a/crates/core/src/client/config.rs b/crates/core/src/client/config.rs index 787a46c5..2ae9970d 100644 --- a/crates/core/src/client/config.rs +++ b/crates/core/src/client/config.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc}; use phoenix_channels_client::JSON; -use crate::{callbacks::*, live_socket::Method}; +use crate::callbacks::*; #[derive(uniffi::Enum, Debug, Clone, Default, Copy)] pub enum LogLevel { @@ -135,3 +135,103 @@ impl std::fmt::Debug for LiveViewClientConfiguration { .finish() } } + +/// An action taken with respect to the history stack +/// when [NavCtx::navigate] is executed. defaults to +/// Push behavior. +#[derive(uniffi::Enum, Default, Clone)] +pub enum NavAction { + /// Push the navigation event onto the history stack. + #[default] + Push, + /// Replace the current top of the history stack with this navigation event. + Replace, +} + +/// Options for calls to [NavCtx::navigate] and the external [LiveViewClient::navigate] function +/// Slightly different from [NavActionOptions] +#[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, + /// Ephemeral extra information to be pushed to the even handler. + #[uniffi(default = None)] + pub extra_event_info: Option>, + /// Persistent state, intended to be deserialized for user specific purposes when + /// revisiting a given view. + #[uniffi(default = None)] + 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>, +} + +/// Connection Options for the initial dead render fetch +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Default)] +pub struct DeadRenderFetchOpts { + #[uniffi(default = None)] + pub headers: Option>, + #[uniffi(default = None)] + pub body: Option>, + #[uniffi(default = None)] + pub method: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +#[repr(u8)] +pub enum Method { + Get = 0, + Options, + Post, + Put, + Delete, + Head, + Trace, + Connect, + Patch, +} + +use reqwest::Method as ReqMethod; +impl From for ReqMethod { + fn from(val: Method) -> ReqMethod { + match val { + Method::Options => ReqMethod::OPTIONS, + Method::Get => ReqMethod::GET, + Method::Post => ReqMethod::POST, + Method::Put => ReqMethod::PUT, + Method::Delete => ReqMethod::DELETE, + Method::Head => ReqMethod::HEAD, + Method::Trace => ReqMethod::TRACE, + Method::Connect => ReqMethod::CONNECT, + Method::Patch => ReqMethod::PATCH, + } + } +} + +pub struct UploadConfig { + pub chunk_size: u64, + pub max_file_size: u64, + pub max_entries: u64, +} + +/// Defaults from https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3 +impl Default for UploadConfig { + fn default() -> Self { + Self { + chunk_size: 64_000, + max_file_size: 8000000, + max_entries: 1, + } + } +} diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/client/inner/channel.rs similarity index 80% rename from crates/core/src/live_socket/channel.rs rename to crates/core/src/client/inner/channel.rs index 94de39e8..f91a7cc4 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/client/inner/channel.rs @@ -1,15 +1,19 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; -use futures::{future::FutureExt, pin_mut, select}; -use log::{debug, error}; +use log::{debug, error, trace}; use phoenix_channels_client::{Channel, Event, Number, Payload, Socket, Topic, JSON}; -use super::UploadConfig; +use super::{dead_render::SessionData, LiveViewClientConfiguration}; use crate::{ callbacks::*, + client::UploadConfig, diff::fragment::{Root, RootDiff}, dom::{ffi::Document as FFiDocument, AttributeName, AttributeValue, Document, Selector}, - error::*, + error::{LiveSocketError, *}, }; #[derive(uniffi::Object)] @@ -169,52 +173,6 @@ impl LiveChannel { Ok(upload_id) } - /// Blocks indefinitely, processing changes to the document using the user provided callback - /// In `set_event_handler` - pub async fn merge_diffs(&self) -> Result<(), LiveSocketError> { - // TODO: This should probably take the event closure to send changes back to swift/kotlin - let document = self.document.clone(); - let events = self.channel.events(); - let statuses = self.channel.statuses(); - loop { - let event = events.event().fuse(); - let status = statuses.status().fuse(); - - pin_mut!(event, status); - - select! { - e = event => { - let e = e?; - match e.event { - Event::Phoenix { phoenix } => { - error!("Phoenix Event for {phoenix:?} is unimplemented"); - } - Event::User { user } => { - if user == "diff" { - let Payload::JSONPayload { json } = e.payload else { - error!("Diff was not json!"); - continue; - }; - - debug!("PAYLOAD: {json:?}"); - // This function merges and uses the event handler set in `set_event_handler` - // which will call back into the Swift/Kotlin. - document.merge_fragment_json(&json.to_string())?; - } - } - }; - } - new_status = status => { - match new_status? { - phoenix_channels_client::ChannelStatus::Left => return Ok(()), - phoenix_channels_client::ChannelStatus::ShutDown => return Ok(()), - _ => {}, - } - } - }; - } - } - pub fn join_payload(&self) -> Payload { self.join_payload.clone() } @@ -458,3 +416,106 @@ impl LiveChannel { Ok(()) } } + +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/channel_init.rs b/crates/core/src/client/inner/channel_init.rs deleted file mode 100644 index 5177e8e6..00000000 --- a/crates/core/src/client/inner/channel_init.rs +++ /dev/null @@ -1,119 +0,0 @@ -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/dead_render.rs b/crates/core/src/client/inner/dead_render.rs new file mode 100644 index 00000000..9d642cbf --- /dev/null +++ b/crates/core/src/client/inner/dead_render.rs @@ -0,0 +1,336 @@ +use core::str; +use std::{collections::HashMap, time::Duration}; + +use log::{debug, trace}; +use phoenix_channels_client::{Payload, JSON}; +use reqwest::{header::LOCATION, Client, Url}; +use serde::Serialize; + +use crate::{ + client::{DeadRenderFetchOpts, Method}, + dom::{AttributeName, Document, ElementName, Selector}, + error::LiveSocketError, +}; + +const MAX_REDIRECTS: usize = 10; +const LVN_VSN: &str = "2.0.0"; +const LVN_VSN_KEY: &str = "vsn"; +const CSRF_KEY: &str = "_csrf_token"; +const MOUNT_KEY: &str = "_mounts"; +const FMT_KEY: &str = "_format"; + +/// Static information ascertained from the dead render when connecting. +#[derive(Clone, Debug)] +pub struct SessionData { + pub connect_opts: DeadRenderFetchOpts, + /// Cross site request forgery, security token, sent with dead render. + pub csrf_token: String, + /// The id of the phoenix channel to join. + pub phx_id: String, + pub phx_static: String, + pub phx_session: String, + pub url: Url, + /// One of `swift`, `kotlin` or `html` indicating the developer platform. + pub format: String, + /// An html page that on the web would be used to bootstrap the web socket connection. + pub dead_render: Document, + pub style_urls: Vec, + /// Whether or not the dead render contains a live reload iframe for development mode. + pub has_live_reload: bool, +} + +//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, + timeout: Duration, + connect_opts: DeadRenderFetchOpts, + client: Client, + ) -> Result { + // NEED: + // these from inside data-phx-main + // data-phx-session, + // data-phx-static + // id + // + // Top level: + // csrf-token + // "iframe[src=\"/phoenix/live_reload/frame\"]" + + let (dead_render, url) = + get_dead_render(url, format, &connect_opts, timeout, 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)?; + + let mut phx_id: Option = None; + let mut phx_static: Option = None; + let mut phx_session: Option = None; + + let main_div_attributes = dead_render + .select(Selector::Attribute(AttributeName { + name: "data-phx-main".into(), + namespace: None, + })) + .last(); + + trace!("main div attributes: {main_div_attributes:?}"); + + let main_div_attributes = dead_render + .select(Selector::Attribute(AttributeName { + namespace: None, + name: "data-phx-main".into(), + })) + .last() + .map(|node_ref| dead_render.get(node_ref)) + .map(|main_div| main_div.attributes()) + .ok_or(LiveSocketError::PhoenixMainMissing)?; + + for attr in main_div_attributes { + if attr.name.name == "id" { + phx_id.clone_from(&attr.value) + } else if attr.name.name == "data-phx-session" { + phx_session.clone_from(&attr.value) + } else if attr.name.name == "data-phx-static" { + phx_static.clone_from(&attr.value) + } + } + 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)?; + trace!("phx_id = {phx_id:?}, session = {phx_session:?}, static = {phx_static:?}"); + + // A Style looks like: + //