├── swift └── .gitkeep ├── config ├── runtime.exs ├── prod.exs ├── dev.exs ├── test.exs └── config.exs ├── .DS_Store ├── test ├── test_helper.exs ├── support │ ├── interceptors │ │ └── socket.ex │ ├── utils.ex │ ├── echo_server.ex │ └── grpc_integration_test_case.ex ├── crane │ ├── uri_test.exs │ ├── browser │ │ ├── window │ │ │ ├── websocket_test.exs │ │ │ └── history_test.exs │ │ └── window_test.exs │ └── browser_test.exs ├── crane_test.exs └── integration │ └── usage_test.exs ├── .formatter.exs ├── CraneDemo ├── CraneDemo │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── image (1).png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── CraneDemo.entitlements │ ├── CraneDemoApp.swift │ ├── BrowserTabs.swift │ └── ContentView.swift └── CraneDemo.xcodeproj │ ├── project.xcworkspace │ └── contents.xcworkspacedata │ ├── xcshareddata │ └── xcschemes │ │ └── CraneDemo.xcscheme │ └── project.pbxproj ├── priv └── protos │ ├── empty.proto │ ├── browser │ ├── header.proto │ ├── document.proto │ ├── document │ │ ├── node │ │ │ └── attribute.proto │ │ └── node.proto │ ├── window │ │ ├── history │ │ │ └── frame.proto │ │ ├── history.proto │ │ ├── socket │ │ │ └── message.proto │ │ └── socket.proto │ ├── request.proto │ ├── response.proto │ └── window.proto │ ├── browser.proto │ └── elixirpb.proto ├── lib ├── crane │ ├── uri.ex │ ├── phoenix │ │ ├── router.ex │ │ ├── live │ │ │ ├── console │ │ │ │ ├── window_state.ex │ │ │ │ ├── browser_state.ex │ │ │ │ ├── logo.ex │ │ │ │ └── html.ex │ │ │ └── console.ex │ │ └── layout.ex │ ├── application.ex │ ├── browser │ │ ├── window │ │ │ ├── console.ex │ │ │ ├── history.ex │ │ │ ├── logger.ex │ │ │ └── web_socket.ex │ │ └── window.ex │ ├── fuse.ex │ ├── utils.ex │ └── browser.ex └── crane.ex ├── Sources └── Crane │ ├── grpc-swift-proto-generator-config.json │ └── Crane.swift ├── .gitignore ├── README.md ├── Package.swift ├── mix.exs └── mix.lock /swift/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liveview-native/crane/HEAD/.DS_Store -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix_playground, live_reload: false 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Bandit.start_link(plug: EchoPlug, port: 4567) 2 | ExUnit.start() 3 | System.no_halt(false) 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix_playground, live_reload: true 4 | 5 | # config :live_debugger, browser_features?: true 6 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo/Assets.xcassets/AppIcon.appiconset/image (1).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liveview-native/crane/HEAD/CraneDemo/CraneDemo/Assets.xcassets/AppIcon.appiconset/image (1).png -------------------------------------------------------------------------------- /priv/protos/empty.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "elixirpb.proto"; 4 | 5 | option (elixirpb.file).module_prefix = "Crane.Protos"; 6 | option swift_prefix = "Crane"; 7 | 8 | message Empty {} 9 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/support/interceptors/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.TestInterceptors.Socket do 2 | 3 | def init(_args) do 4 | [] 5 | end 6 | 7 | def call(request, stream, next, _options) do 8 | next.(request, stream) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/crane/uri.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.URI do 2 | def normalize(uri) do 3 | case URI.parse(uri) do 4 | %URI{scheme: nil} = uri -> %URI{uri | scheme: "https"} 5 | uri -> uri 6 | end 7 | |> URI.to_string() 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/support/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Test.Utils do 2 | def pid_for(%{name: name}), 3 | do: Process.whereis(name) 4 | 5 | def start_pubsub(config) do 6 | ExUnit.Callbacks.start_supervised!({Phoenix.PubSub, name: config.test}) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /priv/protos/browser/header.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "elixirpb.proto"; 4 | 5 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser"; 6 | option swift_prefix = "Crane"; 7 | 8 | message Header { 9 | string name = 1; 10 | string value = 2; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, :level, :debug 4 | config :logger, :backends, [] 5 | 6 | config :crane, fetch_req_options: [ 7 | plug: {Req.Test, Crane.Browser.Window} 8 | ] 9 | 10 | config :crane, interceptors: [ 11 | Crane.TestInterceptors.Socket 12 | ] 13 | -------------------------------------------------------------------------------- /test/crane/uri_test.exs: -------------------------------------------------------------------------------- 1 | # defmodule Crane.UriTest do 2 | # use ExUnit.Case 3 | # 4 | # describe "normalize" do 5 | # test "will set default https scheme when lacking one" do 6 | # assert Crane.URI.normalize("dockyard.com") == "https://dockyard.com" 7 | # end 8 | # end 9 | # end 10 | -------------------------------------------------------------------------------- /priv/protos/browser/document.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "browser/document/node.proto"; 4 | import "elixirpb.proto"; 5 | 6 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser"; 7 | option swift_prefix = "Crane"; 8 | 9 | message Document { 10 | repeated Node nodes = 4; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /priv/protos/browser/document/node/attribute.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "elixirpb.proto"; 4 | 5 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser.Document.Node"; 6 | option swift_prefix = "Crane"; 7 | 8 | message Attribute { 9 | string name = 1; 10 | string value = 2; 11 | } 12 | -------------------------------------------------------------------------------- /priv/protos/browser/window/history/frame.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "elixirpb.proto"; 4 | 5 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser.Window.History"; 6 | option swift_prefix = "Crane"; 7 | 8 | message Frame { 9 | map state = 1; 10 | string url = 2; 11 | } 12 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "image (1).png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /priv/protos/browser/window/history.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "browser/window/history/frame.proto"; 4 | import "elixirpb.proto"; 5 | 6 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser.Window"; 7 | option swift_prefix = "Crane"; 8 | 9 | message History { 10 | int32 index = 1; 11 | repeated Frame stack = 2; 12 | } 13 | -------------------------------------------------------------------------------- /priv/protos/browser/window/socket/message.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "elixirpb.proto"; 4 | 5 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser.Window.Socket"; 6 | option swift_prefix = "Crane"; 7 | 8 | message Message { 9 | string type = 1; 10 | string data = 2; 11 | string socket_name = 3; 12 | } 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /priv/protos/browser/request.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "browser/header.proto"; 4 | import "elixirpb.proto"; 5 | 6 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser"; 7 | option swift_prefix = "Crane"; 8 | 9 | message Request { 10 | string window_name = 1; 11 | string url = 2; 12 | string method = 3; 13 | repeated Header headers = 4; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo/CraneDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # config :logger, :level, :debug 11 | # config :logger, :backends, [] 12 | 13 | import_config "#{config_env()}.exs" 14 | -------------------------------------------------------------------------------- /lib/crane/phoenix/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Phoenix.Router do 2 | use Phoenix.Router 3 | import Phoenix.LiveView.Router 4 | 5 | pipeline :browser do 6 | plug :accepts, ["html"] 7 | plug :fetch_session 8 | plug :put_root_layout, html: {PhoenixPlayground.Layout, :root} 9 | plug :put_secure_browser_headers 10 | end 11 | 12 | scope "/" do 13 | pipe_through :browser 14 | 15 | live "/", Crane.Phoenix.Live.Console 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /Sources/Crane/grpc-swift-proto-generator-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "generate": { 3 | "clients": true, 4 | "servers": false, 5 | "messages": true 6 | }, 7 | "generatedSource": { 8 | "accessLevelOnImports": false, 9 | "accessLevel": "internal" 10 | }, 11 | "protoc": { 12 | "executablePath": "/opt/homebrew/bin/protoc", 13 | "importPaths": [ 14 | "../../priv/protos" 15 | ] 16 | } 17 | } -------------------------------------------------------------------------------- /priv/protos/browser/document/node.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "browser/document/node/attribute.proto"; 4 | import "elixirpb.proto"; 5 | 6 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser.Document"; 7 | option swift_prefix = "Crane"; 8 | 9 | message Node { 10 | string type = 1; 11 | string tag_name = 2; 12 | repeated Attribute attributes = 3; 13 | repeated Node children = 4; 14 | string text_content = 5; 15 | int32 id = 6; 16 | } 17 | 18 | -------------------------------------------------------------------------------- /priv/protos/browser/response.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "browser/document.proto"; 4 | import "browser/header.proto"; 5 | import "browser/window/history.proto"; 6 | import "elixirpb.proto"; 7 | 8 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser"; 9 | option swift_prefix = "Crane"; 10 | 11 | message Response { 12 | string body = 1; 13 | 14 | map view_trees = 2; 15 | repeated string stylesheets = 3; 16 | repeated Header headers = 4; 17 | int32 status = 5; 18 | History history = 6; 19 | } 20 | -------------------------------------------------------------------------------- /priv/protos/browser.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "empty.proto"; 4 | import "browser/header.proto"; 5 | import "elixirpb.proto"; 6 | 7 | option (elixirpb.file).module_prefix = "Crane.Protos"; 8 | option swift_prefix = "Crane"; 9 | 10 | service BrowserService { 11 | rpc New(Empty) returns (Browser) {}; 12 | rpc Get(Browser) returns (Browser) {}; 13 | rpc CloseWindows(Browser) returns (Empty) {}; 14 | } 15 | 16 | message Browser { 17 | string name = 1; 18 | repeated string windows = 2; 19 | repeated Header headers = 3; 20 | } 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/support/echo_server.ex: -------------------------------------------------------------------------------- 1 | defmodule EchoServer do 2 | def init(_args) do 3 | {:ok, []} 4 | end 5 | 6 | def handle_in({"ping", [opcode: :text]}, state) do 7 | {:reply, :ok, {:text, "pong"}, state} 8 | end 9 | end 10 | 11 | defmodule EchoPlug do 12 | use Plug.Router 13 | 14 | plug Plug.Logger 15 | plug :match 16 | plug :dispatch 17 | 18 | get "/websocket" do 19 | conn 20 | |> WebSockAdapter.upgrade(EchoServer, [], timeout: 60_000_000) 21 | |> halt() 22 | end 23 | 24 | match _ do 25 | send_resp(conn, 404, "not found") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo/CraneDemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CraneDemoApp.swift 3 | // CraneDemo 4 | // 5 | // Created by Carson.Katri on 3/11/25. 6 | // 7 | 8 | import SwiftUI 9 | import ElixirKitCrane 10 | 11 | @main 12 | struct CraneDemoApp: App { 13 | @State private var server: ElixirKitCrane 14 | 15 | init() { 16 | setenv("GRPC_PORT", String(port), 0) 17 | self._server = .init(wrappedValue: ElixirKitCrane()) 18 | } 19 | 20 | var body: some Scene { 21 | WindowGroup { 22 | ContentView() 23 | .environment(server) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/crane/phoenix/live/console/window_state.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Phoenix.Live.Console.WindowState do 2 | defstruct active_tab: "Logs", 3 | tab_state: %{}, 4 | view_tree: [] 5 | 6 | @behaviour Access 7 | 8 | def build(windows) do 9 | Enum.reduce(windows, %{}, fn(window, states) -> 10 | Map.put(states, window.name, %__MODULE__{}) 11 | end) 12 | end 13 | 14 | def fetch(%__MODULE__{} = window_state, key) do 15 | Map.fetch(window_state, key) 16 | end 17 | 18 | def get_and_update(%__MODULE__{} = window_state, key, fun) do 19 | Map.get_and_update(window_state, key, fun) 20 | end 21 | 22 | def pop(%__MODULE__{} = window_state, key) do 23 | Map.pop(window_state, key) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /priv/protos/browser/window.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "browser/request.proto"; 4 | import "browser/response.proto"; 5 | import "elixirpb.proto"; 6 | 7 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser"; 8 | option swift_prefix = "Crane"; 9 | 10 | service WindowService { 11 | rpc New(Window) returns (Window) {}; 12 | rpc Visit(Request) returns (Response) {}; 13 | rpc Fetch(Request) returns (Response) {}; 14 | rpc Refresh(Window) returns (Response) {}; 15 | rpc Forward(Window) returns (Response) {}; 16 | rpc Back(Window) returns (Response) {}; 17 | rpc Close(Window) returns (Window) {}; 18 | } 19 | 20 | message Window { 21 | string name = 1; 22 | string browser_name = 2; 23 | repeated string sockets = 3; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /lib/crane/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | live_reload = Application.get_env(:phoenix_playground, :live_reload) 6 | 7 | phx_port = System.get_env("PHX_PORT", "4000") |> make_integer() 8 | 9 | children = [ 10 | {PhoenixPlayground, plug: Crane.Phoenix.Router, live_reload: live_reload, open_browser: false, ip: {0, 0, 0, 0}, port: phx_port}, 11 | {Crane, []}, 12 | ] 13 | 14 | opts = [strategy: :one_for_one, name: __MODULE__] 15 | Supervisor.start_link(children, opts) 16 | end 17 | 18 | defp make_integer(integer) when is_integer(integer), 19 | do: integer 20 | defp make_integer(integer) when is_binary(integer) do 21 | {integer, _} = Integer.parse(integer) 22 | 23 | integer 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | crane-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | .DS_Store 26 | 27 | Package.resolved 28 | Sources/Crane/generated/**/* 29 | .build 30 | .swiftpm 31 | .vscode 32 | **/xcuserdata 33 | 34 | ElixirKitCrane 35 | 36 | /_elixir_kit_build/ -------------------------------------------------------------------------------- /lib/crane/browser/window/console.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser.Window.Console do 2 | use GenServer 3 | 4 | import Crane.Utils 5 | 6 | defstruct logs: %{}, 7 | name: nil, 8 | view_tree: nil, 9 | window_name: nil 10 | 11 | def start_link(console) do 12 | name = generate_name(:console) 13 | 14 | GenServer.start_link(__MODULE__, %__MODULE__{console | name: name}, name: name) 15 | end 16 | 17 | def init(console) do 18 | {:ok, console} 19 | end 20 | 21 | def handle_cast({:log, type, message}, %__MODULE__{logs: logs} = console) do 22 | logs = Map.update(logs, type, [], fn(entries) -> 23 | List.insert_at(entries, -1, message) 24 | end) 25 | 26 | Phoenix.PubSub.broadcast(PhoenixPlayground.PubSub, "logger", {:foobar, "123"}) 27 | 28 | {:noreply, %__MODULE__{console | logs: logs}} 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crane 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `crane` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:crane, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | ```sh 23 | protoc -I priv/protos --swift_opt=Visibility=Public --swift_out=./Sources/Crane/generated $(find priv/protos -name '*.proto' ! -name 'elixirpb.proto') 24 | protoc -I priv/protos --grpc-swift_out=./Sources/Crane/generated $(find priv/protos -name '*.proto' ! -name 'elixirpb.proto') 25 | ``` 26 | 27 | ```sh 28 | MIX_ENV=prod mix elixirkit ElixirKitCrane --target iphonesimulator-arm64 --target iphoneos 29 | ``` -------------------------------------------------------------------------------- /lib/crane/phoenix/live/console/browser_state.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Phoenix.Live.Console.BrowserState do 2 | alias Crane.Phoenix.Live.Console.WindowState 3 | alias Crane.Browser 4 | 5 | @behaviour Access 6 | 7 | defstruct active_window: nil, 8 | window_states: %{} 9 | 10 | def build(browsers) do 11 | Enum.reduce(browsers, %{}, fn(browser, states) -> 12 | {:ok, windows} = Browser.windows(browser) 13 | 14 | Map.put(states, browser.name, %__MODULE__{ 15 | active_window: Enum.at(windows, 0), 16 | window_states: WindowState.build(windows) 17 | }) 18 | end) 19 | end 20 | 21 | def fetch(%__MODULE__{} = browser_state, key) do 22 | Map.fetch(browser_state, key) 23 | end 24 | 25 | def get_and_update(%__MODULE__{} = browser_state, key, fun) do 26 | Map.get_and_update(browser_state, key, fun) 27 | end 28 | 29 | def pop(%__MODULE__{} = browser_state, key) do 30 | Map.pop(browser_state, key) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /priv/protos/elixirpb.proto: -------------------------------------------------------------------------------- 1 | // Put this file to elixirpb.proto under PROTO_PATH. See `protoc -h` for `--proto_path` option. 2 | // Then import it 3 | // ````proto 4 | // import "elixirpb.proto"; 5 | // ```` 6 | // 7 | // 1047 is allocated to this project by Protobuf 8 | // https://github.com/protocolbuffers/protobuf/blob/master/docs/options.md 9 | 10 | syntax = "proto2"; 11 | 12 | package elixirpb; 13 | 14 | import "google/protobuf/descriptor.proto"; 15 | option swift_prefix = "Crane"; 16 | 17 | // File level options 18 | // 19 | // For example, 20 | // option (elixirpb.file).module_prefix = "Foo"; 21 | message FileOptions { 22 | // Specify a module prefix. This will override package name. 23 | // For example, the package is "hello" and a message is "Request", the message 24 | // will be "Hello.Request". But with module_prefix "Foo", the message will be 25 | // "Foo.Request" 26 | optional string module_prefix = 1; 27 | } 28 | 29 | extend google.protobuf.FileOptions { 30 | optional FileOptions file = 1047; 31 | } 32 | -------------------------------------------------------------------------------- /test/crane/browser/window/websocket_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser.Window.WebSocketTest do 2 | use ExUnit.Case 3 | # alias Plug.Conn 4 | 5 | alias Crane.{ 6 | Browser.Window, 7 | Browser.Window.WebSocket 8 | } 9 | 10 | describe "new" do 11 | test "will connect to an existing websocket server" do 12 | {:ok, socket} = WebSocket.new(%Window{}, url: "http://localhost:4567/websocket") 13 | 14 | pid = self() 15 | 16 | :ok = WebSocket.attach_receiver(socket, pid) 17 | WebSocket.send(socket, {:text, "ping"}) 18 | 19 | assert_receive [{:text, "pong"}] 20 | end 21 | end 22 | 23 | describe "close" do 24 | test "will close socket" do 25 | {:ok, %WebSocket{name: name} = socket} = WebSocket.new(%Window{}, url: "http://localhost:4567/websocket") 26 | 27 | pid = Process.whereis(name) 28 | assert Process.alive?(pid) 29 | 30 | WebSocket.close(socket) 31 | 32 | :timer.sleep(100) 33 | 34 | refute Process.alive?(pid) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /priv/protos/browser/window/socket.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "empty.proto"; 4 | import "browser/header.proto"; 5 | import "browser/window/socket/message.proto"; 6 | import "elixirpb.proto"; 7 | 8 | option (elixirpb.file).module_prefix = "Crane.Protos.Browser.Window"; 9 | option swift_prefix = "Crane"; 10 | 11 | service SocketService { 12 | rpc New(Socket) returns (Socket) {}; 13 | 14 | // gRPC does not support fire-and-forget 15 | // endpoints. All endpoints are blocked 16 | // on a response. But with sockets we want 17 | // fire-and-forget. So any clients implementing this 18 | // should spawn a new process to asynchronously run 19 | // the send request to the server. The response is 20 | // always `Empty` so there's no need to use it. 21 | rpc Send(Message) returns (Empty) {}; 22 | rpc Receive(Socket) returns (stream Message) {}; 23 | } 24 | 25 | message Socket { 26 | string name = 1; 27 | string window_name = 2; 28 | string url = 3; 29 | repeated Header headers = 4; 30 | } 31 | 32 | 33 | -------------------------------------------------------------------------------- /lib/crane/fuse.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Fuse do 2 | alias Req.Response 3 | 4 | def run_middleware(:visit, %Response{status: 200, body: body}) do 5 | {:ok, view_tree} = LiveViewNative.Template.Parser.parse_document(body, 6 | strip_comments: true, 7 | text_as_node: true, 8 | inject_identity: true) 9 | 10 | stylesheets = Floki.find(view_tree, "Style") |> Floki.attribute("url") 11 | 12 | view_trees = %{ 13 | document: view_tree, 14 | body: Floki.find(view_tree, "body > *"), 15 | loading: lifecycle_template(view_tree, "loading"), 16 | disconnected: lifecycle_template(view_tree, "disconnected"), 17 | reconnecting: lifecycle_template(view_tree, "reconnecting"), 18 | error: lifecycle_template(view_tree, "error") 19 | } 20 | 21 | %{ 22 | view_trees: view_trees, 23 | stylesheets: stylesheets 24 | } 25 | end 26 | 27 | def run_middleware(:visir, _response), 28 | do: %{view_trees: [], stylesheets: []} 29 | 30 | defp lifecycle_template(view_tree, type) do 31 | Floki.find(view_tree, ~s'head [template="#{type}"') 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/crane_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CraneTest do 2 | use ExUnit.Case 3 | import Crane.Test.Utils 4 | 5 | alias Crane.Browser 6 | 7 | setup config do 8 | Application.put_env(:crane, :pubsub, config.test) 9 | start_pubsub(config) 10 | 11 | on_exit fn -> 12 | Application.delete_env(:crane, :pubsub) 13 | end 14 | 15 | :ok 16 | end 17 | 18 | describe "get" do 19 | test "will get the Crane state" do 20 | {:ok, %Crane{} = _crane} = Crane.get() 21 | 22 | pid = Process.whereis(Crane) 23 | assert Process.alive?(pid) 24 | end 25 | 26 | test "bang value will return without ok" do 27 | %Crane{} = Crane.get!() 28 | end 29 | end 30 | 31 | describe "close_browser" do 32 | test "will close all browsers and return updated crane" do 33 | {:ok, %Browser{} = browser_1, _crane} = Crane.new_browser() 34 | {:ok, %Browser{} = browser_2, crane} = Crane.new_browser() 35 | 36 | browser_1_pid = Process.whereis(browser_1.name) 37 | browser_2_pid = Process.whereis(browser_2.name) 38 | 39 | assert browser_1.name in Map.values(crane.refs) 40 | 41 | {:ok, crane} = Crane.close_browser(browser_1) 42 | 43 | :timer.sleep(10) 44 | 45 | refute Process.alive?(browser_1_pid) 46 | assert Process.alive?(browser_2_pid) 47 | 48 | refute browser_1.name in Map.values(crane.refs) 49 | assert browser_2.name in Map.values(crane.refs) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/integration/usage_test.exs: -------------------------------------------------------------------------------- 1 | # defmodule Crane.Integration.UsageTest do 2 | # use GRPC.Integration.TestCase 3 | # 4 | # alias Crane.GRPC.Browser, as: BrowserServer 5 | # alias Crane.GRPC.Window, as: WindowServer 6 | # alias Crane.Protos.BrowserService.Stub, as: BrowserClient 7 | # alias Crane.Protos.Browser.WindowService.Stub, as: WindowClient 8 | # alias Crane.Protos 9 | # 10 | # setup do 11 | # {:ok, browser, _crane} = Crane.new_browser() 12 | # fetch_req_options = Application.get_env(:crane, :fetch_req_options, []) 13 | # Application.put_env(:crane, :fetch_req_options, []) 14 | # 15 | # on_exit(fn -> 16 | # Application.put_env(:crane, :fetch_req_options, fetch_req_options) 17 | # end) 18 | # 19 | # {:ok, browser: browser} 20 | # end 21 | # 22 | # @tag :skip 23 | # test "bad uri", %{browser: browser} do 24 | # run_server([BrowserServer, WindowServer], fn port -> 25 | # {:ok, channel} = GRPC.Stub.connect("localhost:#{port}") 26 | # 27 | # request = Crane.Browser.to_proto(browser) 28 | # 29 | # {:ok, browser} = BrowserClient.get(channel, request) 30 | # 31 | # {:ok, window} = WindowClient.new(channel, %Protos.Browser.Window{browser_name: browser.name}) 32 | # 33 | # request = %Protos.Browser.Request{ 34 | # url: "bad-uri", 35 | # window_name: window.name 36 | # } 37 | # 38 | # {:ok, response} = WindowClient.visit(channel, request) 39 | # 40 | # assert response.status == 400 41 | # end) 42 | # end 43 | # end 44 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Crane", 7 | platforms: [.iOS(.v18), .macOS(.v15)], 8 | products: [ 9 | .library( 10 | name: "Crane", 11 | targets: ["Crane"] 12 | ), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/grpc/grpc-swift.git", from: "2.0.0"), 16 | .package(url: "https://github.com/grpc/grpc-swift-protobuf.git", from: "1.0.0"), 17 | .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "1.0.0"), 18 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), 19 | 20 | .package(url: "https://github.com/liveview-native/liveview-native-core", from: "0.4.1-rc-2"), 21 | ], 22 | targets: [ 23 | .target( 24 | name: "Crane", 25 | dependencies: [ 26 | .product(name: "GRPCCore", package: "grpc-swift"), 27 | .product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"), 28 | .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), 29 | .product(name: "LiveViewNativeCore", package: "liveview-native-core"), 30 | // .product(name: "ArgumentParser", package: "swift-argument-parser"), 31 | ], 32 | plugins: [ 33 | .plugin(name: "GRPCProtobufGenerator", package: "grpc-swift-protobuf") 34 | ] 35 | ) 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /lib/crane/browser/window/history.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser.Window.History do 2 | @option_keys [:url, :method, :headers] 3 | 4 | defstruct stack: [], 5 | index: -1 6 | 7 | def go(%__MODULE__{index: index, stack: stack} = history, offset \\ 0) do 8 | case Enum.at(stack, index + offset) do 9 | nil -> {:error, "no history frame in stack for index offset of #{offset} from #{index}"} 10 | frame -> {:ok, frame, %{history | index: index + offset}} 11 | end 12 | end 13 | 14 | def push_state(%__MODULE__{index: index, stack: stack} = history, state, options) when is_map(state) and is_list(options) do 15 | case Keyword.has_key?(options, :url) do 16 | false -> {:error, ":url option must be passed in"} 17 | true -> 18 | index = index + 1 19 | 20 | {kept_stack, _tossed_stack} = Enum.split(stack, index) 21 | options = Keyword.take(options, @option_keys) 22 | frame = {state, options} 23 | 24 | { 25 | :ok, 26 | frame, 27 | %{history | stack: List.insert_at(kept_stack, index, frame), index: index} 28 | } 29 | end 30 | end 31 | 32 | def replace_state(%__MODULE__{index: index, stack: stack} = history, state, options) when is_map(state) do 33 | case Keyword.has_key?(options, :url) do 34 | false -> {:error, ":url option must be passed in"} 35 | true -> 36 | options = Keyword.take(options, @option_keys) 37 | frame = {state, options} 38 | 39 | { 40 | :ok, 41 | frame, 42 | %{history | stack: List.replace_at(stack, index, frame)} 43 | } 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/support/grpc_integration_test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule GRPC.Integration.TestCase do 2 | use ExUnit.CaseTemplate, async: true 3 | 4 | require Logger 5 | 6 | using do 7 | quote do 8 | import GRPC.Integration.TestCase 9 | end 10 | end 11 | 12 | def run_server(servers, func, port \\ 0, opts \\ []) do 13 | {:ok, _pid, port} = GRPC.Server.start(servers, port, opts) 14 | 15 | try do 16 | func.(port) 17 | after 18 | :ok = GRPC.Server.stop(servers) 19 | end 20 | 21 | end 22 | 23 | def run_endpoint(endpoint, func, port \\ 0) do 24 | {:ok, _pid, port} = GRPC.Server.start_endpoint(endpoint, port) 25 | 26 | try do 27 | func.(port) 28 | after 29 | :ok = GRPC.Server.stop_endpoint(endpoint, []) 30 | end 31 | end 32 | 33 | def reconnect_server(server, port, retry \\ 3) do 34 | result = GRPC.Server.start(server, port) 35 | 36 | case result do 37 | {:ok, _, ^port} -> 38 | result 39 | 40 | {:error, :eaddrinuse} -> 41 | Logger.warning("Got eaddrinuse when reconnecting to #{server}:#{port}. retry: #{retry}") 42 | 43 | if retry >= 1 do 44 | Process.sleep(500) 45 | reconnect_server(server, port, retry - 1) 46 | else 47 | result 48 | end 49 | 50 | _ -> 51 | result 52 | end 53 | end 54 | 55 | def attach_events(event_names) do 56 | test_pid = self() 57 | 58 | handler_id = "handler-#{inspect(test_pid)}" 59 | 60 | :telemetry.attach_many( 61 | handler_id, 62 | event_names, 63 | fn name, measurements, metadata, [] -> 64 | send(test_pid, {name, measurements, metadata}) 65 | end, 66 | [] 67 | ) 68 | 69 | on_exit(fn -> 70 | :telemetry.detach(handler_id) 71 | end) 72 | end 73 | end 74 | 75 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Crane.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :crane, 7 | version: "0.1.0", 8 | elixir: "~> 1.16", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | aliases: aliases(), 13 | ] 14 | end 15 | 16 | defp elixirc_paths(:test), do: ["lib", "test/support"] 17 | defp elixirc_paths(_), do: ["lib"] 18 | 19 | defp aliases do 20 | [ 21 | protoc: &run_protoc/1 22 | ] 23 | end 24 | 25 | def application do 26 | [ 27 | mod: {Crane.Application, []}, 28 | extra_applications: [:logger] 29 | ] 30 | end 31 | 32 | defp run_protoc(_args) do 33 | System.cmd("sh", [ 34 | "-c", 35 | "rm -rf lib/crane/protos/*", 36 | ]) 37 | System.cmd("sh", [ 38 | "-c", 39 | "protoc -I priv/protos --elixir_out=plugins=grpc,paths=source_relative:./lib/crane/protos $(find priv/protos -name '*.proto' ! -name 'elixirpb.proto')" 40 | ]) 41 | end 42 | 43 | defp deps do 44 | [ 45 | {:req, "~> 0.5"}, 46 | {:phoenix_playground, github: "bcardarella/phoenix_playground", branch: "bc-release-compat"}, 47 | {:mint_web_socket, "~> 1.0.4"}, 48 | {:floki, "~> 0.37"}, 49 | {:websockex, "~> 0.4"}, 50 | {:http_cookie, "~> 0.7"}, 51 | {:public_suffix, github: "axelson/publicsuffix-elixir"}, 52 | {:live_view_native, github: "liveview-native/live_view_native"}, 53 | {:test_server, "~> 0.1", only: :test}, 54 | {:bandit, "~> 1.0"}, 55 | {:cdpotion, "~> 0.1.0"}, 56 | # {:live_debugger, "~> 0.1.4", only: :dev}, 57 | {:live_debugger, path: "../live-debugger", only: :dev}, 58 | 59 | {:elixirkit, github: "liveview-native/elixirkit", branch: "main"}, 60 | 61 | {:plug_crypto, github: "elixir-plug/plug_crypto", branch: "main", override: true}, 62 | ] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/crane/browser/window/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser.Window.Logger do 2 | use GenServer 3 | 4 | import Crane.Utils 5 | 6 | require Logger 7 | 8 | defstruct name: nil, 9 | window_name: nil, 10 | messages: [] 11 | 12 | def start_link(args) when is_list(args) do 13 | name = generate_name(:logger) 14 | GenServer.start_link(__MODULE__, [{:name, name} | args], name: name) 15 | end 16 | 17 | @impl true 18 | def init(args) when is_list(args) do 19 | {:ok, %__MODULE__{ 20 | name: args[:name], 21 | window_name: args[:window].name 22 | }} 23 | end 24 | 25 | @impl true 26 | def handle_call(:get, _from, logger) do 27 | {:reply, {:ok, logger}, logger} 28 | end 29 | 30 | @impl true 31 | def handle_cast({:new_message, message}, %__MODULE__{messages: messages} = logger) do 32 | broadcast(logger, {:new_message, message}) 33 | {:noreply, %__MODULE__{logger | messages: List.insert_at(messages, -1, message)}} 34 | end 35 | 36 | for level <- Logger.levels() do 37 | def unquote(level)(%__MODULE__{name: name}, message) do 38 | GenServer.cast(name, {:new_message, %{type: "Logs", level: unquote(level), message: message}}) 39 | end 40 | end 41 | 42 | def network(%__MODULE__{name: name}, details) do 43 | GenServer.cast(name, {:new_message, %{type: "Network", details: details}}) 44 | end 45 | 46 | def get(%__MODULE__{name: name}), 47 | do: get(name) 48 | 49 | def get(name) when is_binary(name), 50 | do: get(String.to_existing_atom(name)) 51 | 52 | def get(name) when is_atom(name) do 53 | GenServer.call(name, :get) 54 | end 55 | 56 | def get!(resource_or_name) do 57 | {:ok, logger} = get(resource_or_name) 58 | logger 59 | end 60 | 61 | def new(args) when is_list(args) do 62 | with {:ok, pid} <- start_link(args), 63 | {:ok, logger} <- GenServer.call(pid, :get) do 64 | {:ok, logger} 65 | else 66 | error -> {:error, error} 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/crane/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Utils do 2 | def generate_name(type) when is_atom(type), 3 | do: generate_name(Atom.to_string(type)) 4 | def generate_name(type) do 5 | type <> "-" <> 6 | (:crypto.hash(:sha, "#{:erlang.system_time(:nanosecond)}") 7 | |> Base.encode32(case: :lower)) 8 | |> String.to_atom() 9 | end 10 | 11 | def get_reference_names(refs, type) do 12 | Enum.filter(refs, fn({_ref, name}) -> 13 | case Atom.to_string(name) do 14 | ^type <> "-" <> _id -> true 15 | _other -> false 16 | end 17 | end) 18 | end 19 | 20 | def get_reference_resource(refs, type, func) do 21 | type = Atom.to_string(type) 22 | 23 | Enum.reduce(refs, [], fn({_ref, name}, acc) -> 24 | case Atom.to_string(name) do 25 | ^type <> "-" <> _id = name -> 26 | {:ok, resource} = func.(name) 27 | [resource | acc] 28 | _other -> acc 29 | end 30 | end) 31 | end 32 | 33 | def monitor(reffable, refs) do 34 | pid = Process.whereis(reffable.name) 35 | ref = Process.monitor(pid) 36 | # I put the name as a string because the atom 37 | # value is only used once when tearing down the monitored 38 | # prcess but the string match in other functions 39 | # is used frequently 40 | # If that balance ever changes this should change to 41 | # an atom by default 42 | Map.put(refs, ref, reffable.name) 43 | end 44 | 45 | def demonitor(ref, refs) do 46 | Process.demonitor(ref) 47 | Map.delete(refs, ref) 48 | end 49 | 50 | def subscribe(%{name: topic}), 51 | do: subscribe(topic) 52 | 53 | def subscribe(topic) when is_atom(topic), 54 | do: Atom.to_string(topic) |> subscribe() 55 | 56 | def subscribe(topic) do 57 | pubsub = Application.get_env(:crane, :pubsub, PhoenixPlayground.PubSub) 58 | :ok = Phoenix.PubSub.subscribe(pubsub, topic) 59 | end 60 | 61 | def unsubscribe(%{name: topic}), 62 | do: unsubscribe(topic) 63 | 64 | def unsubscribe(topic) when is_atom(topic), 65 | do: Atom.to_string(topic) |> unsubscribe() 66 | 67 | def unsubscribe(topic) do 68 | pubsub = Application.get_env(:crane, :pubsub, PhoenixPlayground.PubSub) 69 | :ok = Phoenix.PubSub.unsubscribe(pubsub, topic) 70 | end 71 | 72 | def broadcast(%{name: topic}, message), 73 | do: broadcast(topic, message) 74 | 75 | def broadcast(topic, message) when is_atom(topic), 76 | do: Atom.to_string(topic) |> broadcast(message) 77 | 78 | def broadcast(topic, message) do 79 | pubsub = Application.get_env(:crane, :pubsub, PhoenixPlayground.PubSub) 80 | :ok = Phoenix.PubSub.broadcast(pubsub, topic, message) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/crane.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane do 2 | use GenServer 3 | import Crane.Utils 4 | 5 | alias Crane.Browser 6 | 7 | defstruct refs: %{} 8 | 9 | def start_link([]) do 10 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 11 | end 12 | 13 | def init(_) do 14 | Process.flag(:trap_exit, true) 15 | {:ok, %__MODULE__{}} 16 | end 17 | 18 | def handle_call(:get, _from, crane) do 19 | {:reply, {:ok, crane}, crane} 20 | end 21 | 22 | def handle_call(:browsers, _from, %__MODULE__{refs: refs} = crane) do 23 | browsers = get_reference_resource(refs, :browser, fn(name) -> 24 | Browser.get(name) 25 | end) 26 | |> Enum.sort_by(&(&1.created_at), {:asc, DateTime}) 27 | 28 | {:reply, {:ok, browsers}, crane} 29 | end 30 | 31 | def handle_call(:new_browser, _from, %__MODULE__{refs: refs} = crane) do 32 | with {:ok, browser} <- Browser.new(), 33 | refs <- monitor(browser, refs) do 34 | crane = %__MODULE__{crane | refs: refs} 35 | broadcast(Crane, {:new_browser, browser}) 36 | 37 | {:reply, {:ok, browser, crane}, crane} 38 | else 39 | error -> {:reply, error, crane} 40 | end 41 | end 42 | 43 | def handle_call(_msg, _from, browser) do 44 | {:noreply, browser} 45 | end 46 | 47 | def handle_cast(_msg, browser) do 48 | {:noreply, browser} 49 | end 50 | 51 | def handle_info({:DOWN, ref, :process, _pid, _reason}, %__MODULE__{refs: refs} = crane) do 52 | {_name, refs} = Map.pop(refs, ref) 53 | 54 | {:noreply, %__MODULE__{crane | refs: refs}} 55 | end 56 | 57 | def handle_info(:reconnect, crane) do 58 | IO.puts("RECONNECT") 59 | {:noreply, crane} 60 | end 61 | 62 | def handle_info(_msg, browser) do 63 | {:noreply, browser} 64 | end 65 | 66 | def new_browser do 67 | GenServer.call(__MODULE__, :new_browser) 68 | end 69 | 70 | def new_browser! do 71 | {:ok, browser} = new_browser() 72 | browser 73 | end 74 | 75 | def get do 76 | GenServer.call(__MODULE__, :get) 77 | end 78 | 79 | def get! do 80 | {:ok, crane} = get() 81 | crane 82 | end 83 | 84 | def browsers do 85 | GenServer.call(__MODULE__, :browsers) 86 | end 87 | 88 | def browsers! do 89 | {:ok, browsers} = browsers() 90 | browsers 91 | end 92 | 93 | def close_browser(%Browser{} = browser) do 94 | :ok = Browser.close(browser) 95 | get() 96 | end 97 | 98 | def launch(options) do 99 | with {:ok, browser, _crane} <- new_browser(), 100 | {:ok, window, browser} <- Crane.Browser.new_window(browser), 101 | {:ok, _response, window} <- Crane.Browser.Window.visit(window, options) do 102 | {:ok, window, browser} 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo.xcodeproj/xcshareddata/xcschemes/CraneDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /test/crane/browser/window/history_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser.Window.HistoryTest do 2 | use ExUnit.Case 3 | 4 | alias Crane.Browser.Window.History 5 | 6 | setup do 7 | history = %History{index: 2, stack: [ 8 | {%{}, url: "/"}, 9 | {%{}, url: "/1"}, 10 | {%{}, url: "/2"}, 11 | {%{}, url: "/3"}, 12 | ]} 13 | 14 | {:ok, [history: history]} 15 | end 16 | 17 | describe "go" do 18 | test "()", context do 19 | {:ok, frame, history} = History.go(context.history) 20 | 21 | assert frame == {%{}, url: "/2"} 22 | assert history.index == 2 23 | end 24 | 25 | test "(0)", context do 26 | {:ok, frame, history} = History.go(context.history, 0) 27 | 28 | assert frame == {%{}, url: "/2"} 29 | assert history.index == 2 30 | end 31 | 32 | test "(1) when there is a frame to navigate to in the stack", context do 33 | {:ok, frame, history} = History.go(context.history, 1) 34 | 35 | assert frame == {%{}, url: "/3"} 36 | assert history.index == 3 37 | end 38 | 39 | test "(-1) when there is a frame to navigate to in the stack", context do 40 | {:ok, frame, history} = History.go(context.history, -1) 41 | 42 | assert frame == {%{}, url: "/1"} 43 | assert history.index == 1 44 | end 45 | 46 | test "(4) when there is a frame to navigate to in the stack", context do 47 | {:error, _message} = History.go(context.history, 4) 48 | end 49 | end 50 | 51 | describe "push_state" do 52 | test "when no :url is included in the options", context do 53 | {:error, _message} = History.push_state(context.history, %{}, []) 54 | end 55 | 56 | test "when valid options are passed will add new frame to the end of stack and update index", context do 57 | {:ok, frame, history} = History.push_state(%{context.history | index: 3}, %{}, url: "/4") 58 | 59 | assert frame == {%{}, url: "/4"} 60 | assert history.index == 4 61 | end 62 | 63 | test "when valid options are passed and index is mid-stack will drop remaining stack frames", context do 64 | {:ok, frame, history} = History.push_state(%{context.history | index: 0}, %{}, url: "/4") 65 | 66 | assert frame == {%{}, url: "/4"} 67 | assert history.index == 1 68 | assert history.stack == [ 69 | {%{}, url: "/"}, 70 | {%{}, url: "/4"} 71 | ] 72 | end 73 | 74 | test "when no history in stack don't increment index" do 75 | {:ok, _frame, history} = History.push_state(%History{}, %{}, url: "/") 76 | 77 | assert history.index == 0 78 | assert history.stack == [ 79 | {%{}, url: "/"} 80 | ] 81 | end 82 | end 83 | 84 | describe "replace_state" do 85 | test "when no :url is included in the options", context do 86 | {:error, _message} = History.replace_state(context.history, %{}, []) 87 | end 88 | 89 | test "when valid options are passed will replace the current frame with the new frame", context do 90 | {:ok, frame, history} = History.replace_state(context.history, %{}, url: "/4") 91 | 92 | assert frame == {%{}, url: "/4"} 93 | assert history.index == 2 94 | assert history.stack == [ 95 | {%{}, url: "/"}, 96 | {%{}, url: "/1"}, 97 | {%{}, url: "/4"}, 98 | {%{}, url: "/3"} 99 | ] 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/crane/browser.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser do 2 | use GenServer 3 | import Crane.Utils 4 | 5 | alias HttpCookie.Jar 6 | alias Crane.Browser.Window 7 | 8 | @default_headers [ 9 | {"Accept-Encoding", "gzip, deflate, br, zstd"}, 10 | {"Accept-Language", "en-US,en;q=0.5"}, 11 | {"User-Agent", "Crane/1.0"}, 12 | {"Upgrade-Insecure-Requests", "1"}, 13 | ] 14 | 15 | defstruct name: nil, 16 | refs: %{}, 17 | created_at: nil, 18 | headers: [], 19 | cookie_jar: Jar.new() 20 | 21 | def start_link(args) do 22 | name = generate_name(:browser) 23 | 24 | args = Keyword.put(args, :name, name) 25 | 26 | GenServer.start_link(__MODULE__, args, name: name) 27 | end 28 | 29 | def init(args) do 30 | headers = Keyword.get(args, :headers, []) 31 | Process.flag(:trap_exit, true) 32 | 33 | {:ok, %__MODULE__{ 34 | headers: @default_headers ++ headers, 35 | created_at: DateTime.now!("Etc/UTC"), 36 | name: args[:name] 37 | }} 38 | end 39 | 40 | def handle_call(:get, _from, browser), 41 | do: {:reply, {:ok, browser}, browser} 42 | 43 | def handle_call({:get, %__MODULE__{headers: headers}}, _from, browser) do 44 | browser = %__MODULE__{browser | headers: browser.headers ++ headers} 45 | {:reply, {:ok, browser}, browser} 46 | end 47 | 48 | def handle_call(:windows, _from, %__MODULE__{refs: refs} = browser) do 49 | windows = get_reference_resource(refs, :window, fn(name) -> 50 | Window.get(name) 51 | end) 52 | |> Enum.sort_by(&(&1.created_at), {:asc, DateTime}) 53 | 54 | {:reply, {:ok, windows}, browser} 55 | end 56 | 57 | def handle_call({:restore_window, %Window{} = window_state}, _from, %__MODULE__{refs: refs} = browser) do 58 | with {:ok, window} <- Window.restore(window_state), 59 | refs <- monitor(window, refs) do 60 | 61 | browser = %__MODULE__{browser | refs: refs} 62 | 63 | broadcast(Crane, {:restore_window, window, browser}) 64 | 65 | {:reply, {:ok, window, browser}, browser} 66 | else 67 | error -> {:reply, error, browser} 68 | end 69 | end 70 | 71 | def handle_call(:new_window, _from, %__MODULE__{refs: refs} = browser) do 72 | with {:ok, window} <- Window.new([browser: browser]), 73 | refs <- monitor(window, refs) do 74 | 75 | browser = %__MODULE__{browser | refs: refs} 76 | 77 | broadcast(Crane, {:new_window, window, browser}) 78 | 79 | {:reply, {:ok, window, browser}, browser} 80 | else 81 | error -> {:reply, error, browser} 82 | end 83 | end 84 | 85 | def handle_call(_msg, _from, browser) do 86 | {:noreply, browser} 87 | end 88 | 89 | def handle_cast({:update_cookie_jar, cookie_jar}, browser) do 90 | broadcast(Crane, {:update, browser}) 91 | {:noreply, %__MODULE__{browser | cookie_jar: cookie_jar}} 92 | end 93 | 94 | def handle_cast(_msg, browser) do 95 | {:noreply, browser} 96 | end 97 | 98 | def handle_info({:DOWN, ref, :process, _pid, _reason}, %__MODULE__{refs: refs} = browser) do 99 | {_name, refs} = Map.pop(refs, ref) 100 | 101 | {:noreply, %__MODULE__{browser | refs: refs}} 102 | end 103 | 104 | def handle_info(_msg, browser) do 105 | {:noreply, browser} 106 | end 107 | 108 | def update_cookie_jar(%__MODULE__{name: name}, cookie_jar) do 109 | GenServer.cast(name, {:update_cookie_jar, cookie_jar}) 110 | end 111 | 112 | def windows(%__MODULE__{name: name}) do 113 | GenServer.call(name, :windows) 114 | end 115 | 116 | def windows!(browser) do 117 | {:ok, windows} = windows(browser) 118 | windows 119 | end 120 | 121 | def restore_window(%__MODULE__{name: name}, %Window{} = window_state \\ %Window{}) do 122 | GenServer.call(name, {:restore_window, %Window{window_state | browser_name: name}}) 123 | end 124 | 125 | def new_window(%__MODULE__{name: name}) do 126 | GenServer.call(name, :new_window) 127 | end 128 | 129 | def new_window(name) when is_binary(name) do 130 | new_window(String.to_existing_atom(name)) 131 | end 132 | 133 | def new_window(name) when is_atom(name) do 134 | new_window(%__MODULE__{name: name}) 135 | end 136 | 137 | def new_window!(browser_or_name) do 138 | {:ok, window} = new_window(browser_or_name) 139 | window 140 | end 141 | 142 | def close_window(%__MODULE__{} = browser, %Window{} = window) do 143 | :ok = Window.close(window) 144 | get(browser) 145 | end 146 | 147 | def close_window(browser_name, window_name) do 148 | :ok = Window.close(window_name) 149 | get(browser_name) 150 | end 151 | 152 | def new(state \\ []) when is_list(state) do 153 | with {:ok, pid} <- start_link(state), 154 | {:ok, browser} <- GenServer.call(pid, :get) do 155 | {:ok, browser} 156 | else 157 | error -> {:error, error} 158 | end 159 | end 160 | 161 | def close(%__MODULE__{name: name}) do 162 | GenServer.stop(name, :normal) 163 | end 164 | 165 | def get(%__MODULE__{name: name}), 166 | do: get(name) 167 | 168 | def get(name) when is_binary(name), 169 | do: get(String.to_existing_atom(name)) 170 | 171 | def get(name) when is_atom(name) do 172 | GenServer.call(name, :get) 173 | end 174 | 175 | def get!(resource_or_name) do 176 | {:ok, window} = get(resource_or_name) 177 | window 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/crane/browser/window/web_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser.Window.WebSocket do 2 | use GenServer 3 | 4 | alias Crane.{ 5 | Browser.Window 6 | } 7 | 8 | import Crane.Utils, only: [ 9 | generate_name: 1 10 | ] 11 | 12 | defstruct conn: nil, 13 | window_name: nil, 14 | ref: nil, 15 | websocket: nil, 16 | name: nil, 17 | created_at: nil, 18 | receiver: nil 19 | 20 | def start_link(opts) when is_list(opts) do 21 | opts = 22 | Keyword.put_new_lazy(opts, :name, fn -> 23 | generate_name(:socket) 24 | end) 25 | 26 | GenServer.start_link(__MODULE__, opts, name: opts[:name]) 27 | end 28 | 29 | def init(opts) do 30 | Process.flag(:trap_exit, true) 31 | uri = URI.parse(opts[:url]) 32 | 33 | scheme = String.to_atom(uri.scheme) 34 | 35 | with {:ok, opts} = Keyword.validate(opts, [url: nil, headers: [], window_name: nil, name: nil, receiver: nil]), 36 | {:ok, conn} <- Mint.HTTP1.connect(scheme, uri.host, uri.port), 37 | {:ok, conn, ref} <- Mint.WebSocket.upgrade(ws_scheme(scheme), conn, ws_path(uri.path), opts[:headers]), 38 | http_reply_message <- receive(do: (message -> message)), 39 | {:ok, conn, responses} <- Mint.WebSocket.stream(conn, http_reply_message), 40 | responses <- parse_stream_responses(responses, ref), 41 | {:ok, conn, websocket} <- Mint.WebSocket.new(conn, ref, responses[:status], responses[:headers]) do 42 | socket = %__MODULE__{ 43 | conn: conn, 44 | ref: ref, 45 | created_at: DateTime.now!("Etc/UTC"), 46 | websocket: websocket, 47 | window_name: opts[:window_name], 48 | name: opts[:name], 49 | receiver: opts[:receiver] 50 | } 51 | 52 | {:ok, socket} 53 | else 54 | {:error, error} -> 55 | {:stop, {:shutdown, error}} 56 | error -> 57 | {:stop, {:shutdown, error}} 58 | end 59 | end 60 | 61 | defp parse_stream_responses(responses, ref) do 62 | Enum.reduce(responses, [], fn 63 | {:status, ^ref, status}, acc -> [{:status, status} | acc] 64 | {:headers, ^ref, headers}, acc -> [{:headers, headers} | acc] 65 | _other, acc -> acc 66 | end) 67 | end 68 | 69 | def handle_call(:get, _from, socket) do 70 | {:reply, {:ok, socket}, socket} 71 | end 72 | 73 | def handle_call({:attach_receiver, receiver}, _from, socket) do 74 | {:reply, :ok, %__MODULE__{socket | receiver: receiver}} 75 | end 76 | 77 | def handle_call(_msg, _from, socket) do 78 | {:noreply, socket} 79 | end 80 | 81 | def handle_cast({:send, msg}, %__MODULE__{websocket: websocket, conn: conn, ref: ref} = socket) do 82 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, msg) 83 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data) 84 | 85 | {:noreply, %__MODULE__{socket | websocket: websocket, conn: conn}} 86 | end 87 | 88 | def handle_cast(:disconnect, %__MODULE__{conn: conn} = socket) do 89 | {:ok, conn} = Mint.HTTP.close(conn) 90 | 91 | {:stop, :normal, %__MODULE__{socket | conn: conn}} 92 | end 93 | 94 | def handle_cast(_msg, socket) do 95 | {:noreply, socket} 96 | end 97 | 98 | def handle_info({protocol, _, _data} = msg, %__MODULE__{websocket: websocket, conn: conn, ref: ref, receiver: receiver} = socket) when protocol in [:ssl, :tcp] do 99 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, msg) 100 | {:ok, websocket, msg} = Mint.WebSocket.decode(websocket, data) 101 | 102 | if receiver do 103 | Kernel.send(receiver, msg) 104 | end 105 | 106 | {:noreply, %__MODULE__{socket | websocket: websocket, conn: conn}} 107 | end 108 | 109 | def handle_info({closed_protocol, _}, %__MODULE__{conn: conn} = socket) when closed_protocol in [:tcp_closed, :ssl_closed] do 110 | {:ok, conn} = Mint.HTTP.close(conn) 111 | 112 | {:stop, :normal, %__MODULE__{socket | conn: conn}} 113 | end 114 | 115 | def handle_info(_msg, socket) do 116 | {:noreply, socket} 117 | end 118 | 119 | defp ws_scheme(:http), 120 | do: :ws 121 | defp ws_scheme(:https), 122 | do: :wss 123 | 124 | defp ws_path(nil), 125 | do: "/" 126 | defp ws_path(path), 127 | do: path 128 | 129 | def send(%__MODULE__{name: name}, msg) do 130 | GenServer.cast(name, {:send, msg}) 131 | end 132 | 133 | def close(%__MODULE__{name: name}) do 134 | GenServer.cast(name, :disconnect) 135 | end 136 | 137 | def new(%Window{name: window_name}, options) when is_list(options) do 138 | with options <- Keyword.put(options, :window_name, window_name), 139 | {:ok, pid} <- start_link(options), 140 | {:ok, socket} <- GenServer.call(pid, :get) do 141 | {:ok, socket} 142 | else 143 | error -> 144 | {:error, error} 145 | end 146 | end 147 | 148 | def get(%__MODULE__{name: name}), 149 | do: get(name) 150 | 151 | def get(name) when is_binary(name), 152 | do: get(String.to_existing_atom(name)) 153 | 154 | def get(name) when is_atom(name) do 155 | GenServer.call(name, :get) 156 | end 157 | 158 | def get!(resource_or_name) do 159 | {:ok, window} = get(resource_or_name) 160 | window 161 | end 162 | 163 | def attach_receiver(%__MODULE__{name: name}, receiver) do 164 | GenServer.call(name, {:attach_receiver, receiver}) 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /test/crane/browser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Crane.BrowserTest do 2 | use ExUnit.Case 3 | import Crane.Test.Utils 4 | 5 | alias Crane.{Browser, Browser.Window} 6 | 7 | setup config do 8 | Application.put_env(:crane, :pubsub, config.test) 9 | start_pubsub(config) 10 | 11 | on_exit fn -> 12 | Application.delete_env(:crane, :pubsub) 13 | end 14 | 15 | :ok 16 | end 17 | 18 | describe "new" do 19 | test "will create a new browser" do 20 | {:ok, %Browser{name: name} = _browser} = Browser.new() 21 | 22 | refute is_nil(Process.whereis(name)) 23 | end 24 | 25 | test "will return browser struct with headers assigned" do 26 | headers = [ 27 | {"Foo", "Bar"} 28 | ] 29 | {:ok, %Browser{} = browser} = Browser.new(headers: headers) 30 | 31 | Enum.each(headers, fn(header) -> 32 | assert Enum.member?(browser.headers, header) 33 | end) 34 | end 35 | end 36 | 37 | describe "get" do 38 | setup do 39 | {:ok, browser_pid} = Browser.start_link([]) 40 | {:ok, browser} = GenServer.call(browser_pid, :get) 41 | 42 | on_exit fn -> 43 | Process.exit(browser_pid, :normal) 44 | end 45 | 46 | {:ok, browser: browser} 47 | end 48 | 49 | test "will return the browser struct", %{browser: browser} do 50 | {:ok, %Browser{} = got_browser} = Browser.get(%Browser{name: browser.name}) 51 | 52 | assert browser == got_browser 53 | end 54 | 55 | test "bang value will return without ok", %{browser: browser} do 56 | got_browser = Browser.get!(%Browser{name: browser.name}) 57 | 58 | assert browser == got_browser 59 | end 60 | end 61 | 62 | describe "windows" do 63 | setup do 64 | {:ok, browser_pid} = Browser.start_link([]) 65 | {:ok, browser} = GenServer.call(browser_pid, :get) 66 | 67 | on_exit fn -> 68 | Process.exit(browser_pid, :normal) 69 | end 70 | 71 | {:ok, browser: browser} 72 | end 73 | 74 | test "will return all windows in a tuple", %{browser: browser} do 75 | {:ok, %Window{} = window_1, browser} = Browser.new_window(browser) 76 | {:ok, %Window{} = window_2, browser} = Browser.new_window(browser) 77 | 78 | {:ok, windows} = Browser.windows(browser) 79 | 80 | assert window_1 in windows 81 | assert window_2 in windows 82 | end 83 | 84 | test "will return all windows", %{browser: browser} do 85 | {:ok, %Window{} = window_1, browser} = Browser.new_window(browser) 86 | {:ok, %Window{} = window_2, browser} = Browser.new_window(browser) 87 | 88 | windows = Browser.windows!(browser) 89 | 90 | assert window_1 in windows 91 | assert window_2 in windows 92 | end 93 | 94 | test "will spawn a new window for the browser that is monitored by the browser", %{browser: browser} do 95 | {:ok, %Window{} = window, browser} = Browser.new_window(browser) 96 | 97 | assert window.name in Map.values(browser.refs) 98 | assert window.history.index == -1 99 | 100 | pid = Process.whereis(window.name) 101 | Process.exit(pid, :kill) 102 | 103 | :timer.sleep(10) 104 | 105 | {:ok, browser} = Browser.get(browser) 106 | 107 | refute window.name in Map.values(browser.refs) 108 | end 109 | end 110 | 111 | describe "close" do 112 | test "will close a Browser process" do 113 | {:ok, browser} = Browser.new() 114 | 115 | pid = Process.whereis(browser.name) 116 | 117 | assert Process.alive?(pid) 118 | 119 | :ok = Browser.close(browser) 120 | 121 | refute Process.alive?(pid) 122 | end 123 | 124 | test "when browser closes all windows are closed too" do 125 | {:ok, browser} = Browser.new() 126 | 127 | {:ok, %Window{} = window_1, browser} = Browser.new_window(browser) 128 | {:ok, %Window{} = window_2, browser} = Browser.new_window(browser) 129 | 130 | window_1_pid = Process.whereis(window_1.name) 131 | window_2_pid = Process.whereis(window_2.name) 132 | 133 | :ok = Browser.close(browser) 134 | 135 | :timer.sleep(10) 136 | 137 | refute Process.alive?(window_1_pid) 138 | refute Process.alive?(window_2_pid) 139 | end 140 | end 141 | 142 | describe "close_window" do 143 | test "will close all windows and return updated browser" do 144 | {:ok, browser} = Browser.new() 145 | 146 | {:ok, %Window{} = window_1, browser} = Browser.new_window(browser) 147 | {:ok, %Window{} = window_2, browser} = Browser.new_window(browser) 148 | 149 | window_1_pid = Process.whereis(window_1.name) 150 | window_2_pid = Process.whereis(window_2.name) 151 | 152 | assert window_1.name in Map.values(browser.refs) 153 | 154 | {:ok, browser} = Browser.close_window(browser, window_1) 155 | 156 | :timer.sleep(10) 157 | 158 | refute Process.alive?(window_1_pid) 159 | assert Process.alive?(window_2_pid) 160 | 161 | refute window_1.name in Map.values(browser.refs) 162 | assert window_2.name in Map.values(browser.refs) 163 | end 164 | end 165 | 166 | describe "restore_window" do 167 | test "will restore a previously closed window state" do 168 | {:ok, browser} = Browser.new() 169 | {:ok, window, browser} = Browser.new_window(browser) 170 | 171 | Req.Test.stub(Window, fn(conn) -> 172 | Plug.Conn.send_resp(conn, conn.status || 200, "Success!") 173 | end) 174 | 175 | Req.Test.allow(Window, self(), pid_for(window)) 176 | 177 | {:ok, _response, window} = Window.visit(window, url: "https://dockyard.com") 178 | old_pid = Process.whereis(window.name) 179 | 180 | :ok = Window.close(window) 181 | 182 | {:ok, restored_window, browser} = Browser.restore_window(browser, window) 183 | 184 | assert window.name == restored_window.name 185 | assert window.history == restored_window.history 186 | assert window.response == restored_window.response 187 | # assert window.view_trees == restored_window.view_trees 188 | assert restored_window.browser_name == browser.name 189 | 190 | refute old_pid == Process.whereis(restored_window.name) 191 | 192 | assert restored_window.name in Map.values(browser.refs) 193 | 194 | pid = Process.whereis(window.name) 195 | Process.exit(pid, :kill) 196 | 197 | :timer.sleep(10) 198 | 199 | {:ok, browser} = Browser.get(browser) 200 | 201 | refute restored_window.name in Map.values(browser.refs) 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /lib/crane/phoenix/live/console/logo.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Phoenix.Live.Console.Logo do 2 | use Phoenix.Component 3 | 4 | def render(assigns) do 5 | ~H""" 6 |
 7 |                                                                                                                           
 8 |                                                                                                                       
 9 |                                                                                                                       
10 |                                                                                                                       
11 |                                                                                                                       
12 |                                                                                                                       
13 |                                                                                                                       
14 |                                                                                                                       
15 |                                                                                                                       
16 |                                                                                                                       
17 |                                                             :::                                                       
18 |                                                           :::::::::::::                                               
19 |                                                          ::::::::::::::::                                             
20 |                                                         ::::         :::                                              
21 |                                                         ::::                                                          
22 |                                                         ::::                                                          
23 |                                                          ::::                                                         
24 |                                                           ::::                                                        
25 |                                                            :::::                                                      
26 |                                                             :::::                                                     
27 |                                                              :::::                                                    
28 |                                                               ::::::                                                  
29 |                                                                 :::::                                                 
30 |                                                                  ::::;                                                
31 |                                                                   ::::                                                
32 |                                                        :::::::     ::::                                               
33 |                                                     :::::::::::    ;:::                                               
34 |                                                  ::::::::           ::::                                              
35 |                                                :::::::              ::::                                              
36 |                                               :::::                 ::::                                              
37 |                                             :::::                  ::::                                               
38 |                                            :::::                  :::::                                               
39 |                                           ::::;                  ::;:;                                                
40 |                                          :::::                  ;:;:;                                                 
41 |                                         :::::                 :;;;;:                                                  
42 |                                        ;::::        :::     ::;;;;                                                    
43 |                                        ::::     ::::::;:   :;;;;                                                      
44 |                                       ::;:: ::;:::::;;:   :;;;                                                        
45 |                                       ::;;;;:;:;::::;;:  :;;;                                                         
46 |                                       ;;;;;;::   ;;;;:  ;;;;;                                                         
47 |                                      ;;;;;;     :;;;;   ;;;;                                                          
48 |                                       :;       :;;;    ;;;;                                                           
49 |                                                ;;;;;   ;;;;                                                           
50 |                                                 :;;;;: ;;;                                                            
51 |                                                   ;;;;;;;;                                                            
52 |                                                    ;;;;;;;                                                            
53 |                                                      ;;;;;                                                            
54 |                                                       ;;;;;;                                                          
55 |                                                        ;;;;;;;                                                        
56 |                                                        ;;;;;;;;:                                                      
57 |                                                        ;;;  ;;;;;                                                     
58 |                                                        ;;;    ;;                                                      
59 |                                                   ;;;;;;;;;;;;;;                                                      
60 |                                                   ;;;;;;;;;;;;;;;                                                     
61 |                                                                                                                       
62 |                                                                                                                       
63 |                                                                                                                       
64 |                                                                                                                       
65 |                                                                                                                       
66 |                                                                                                                       
67 |                                                                                                                       
68 |                                                                                                                       
69 |                                                                                                                       
70 |                                                                                                                       
71 |     
72 | """ 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo/BrowserTabs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowserTabs.swift 3 | // CraneDemo 4 | // 5 | // Created by Carson Katri on 3/13/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BrowserTabsView: View { 11 | @Binding var selectedTab: ID? 12 | let chromeVisible: Bool 13 | 14 | @ViewBuilder let content: () -> Content 15 | @ViewBuilder let newTabForm: () -> NewTabForm 16 | @ViewBuilder let newTabView: () -> NewTabView 17 | @ViewBuilder let tabActions: () -> TabActions 18 | @ViewBuilder let controls: () -> Controls 19 | 20 | @State private var scrollPosition: ScrollPosition = .init(x: 0) 21 | 22 | enum NewTabFormID { 23 | case id 24 | } 25 | 26 | var body: some View { 27 | let content = content() 28 | ScrollView(.horizontal, showsIndicators: false) { 29 | HStack(spacing: 0) { 30 | ForEach(subviews: content) { subview in 31 | VStack { 32 | subview 33 | .scrollDisabled(false) 34 | } 35 | .containerRelativeFrame(.horizontal) 36 | .id(subview.containerValues.browserTabValue) 37 | } 38 | newTabView() 39 | } 40 | .scrollTargetLayout() 41 | } 42 | .scrollDisabled(true) 43 | .scrollPosition($scrollPosition) 44 | .frame(maxHeight: .infinity) 45 | // .safeAreaInset(edge: .bottom) { 46 | .overlay(alignment: .bottom) { 47 | ScrollViewReader { proxy in 48 | VStack { 49 | VStack(spacing: 0) { 50 | Divider() 51 | ScrollView(.horizontal, showsIndicators: false) { 52 | HStack(spacing: 0) { 53 | ForEach(subviews: content) { subview in 54 | (subview.containerValues.browserTabLabel ?? AnyView(Text(""))) 55 | .padding(chromeVisible ? 12 : 0) 56 | .frame(maxWidth: .infinity) 57 | .background { 58 | if chromeVisible { 59 | RoundedRectangle.rect(cornerRadius: 16, style: .continuous) 60 | .fill(.ultraThinMaterial) 61 | } 62 | } 63 | .contextMenu { 64 | tabActions() 65 | } 66 | .compositingGroup() 67 | .shadow(color: .black.opacity(chromeVisible ? 0.2 : 0), radius: 3, y: 2) 68 | .padding(chromeVisible ? 16 : 0) 69 | .containerRelativeFrame(.horizontal) 70 | .id(subview.containerValues.browserTabValue) 71 | } 72 | newTabForm() 73 | .padding(chromeVisible ? 12 : 0) 74 | .frame(maxWidth: .infinity) 75 | .background(.ultraThinMaterial, in: .rect(cornerRadius: 16, style: .continuous)) 76 | .compositingGroup() 77 | .shadow(color: .black.opacity(chromeVisible ? 0.2 : 0), radius: 3, y: 2) 78 | .padding(chromeVisible ? 16 : 0) 79 | .containerRelativeFrame(.horizontal) 80 | .id(NewTabFormID.id) 81 | } 82 | .scrollTargetLayout() 83 | } 84 | .scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne)) 85 | .onScrollGeometryChange(for: CGPoint.self, of: { geometry in 86 | geometry.contentOffset 87 | }, action: { oldValue, newValue in 88 | guard scrollPosition.point?.x != newValue.x else { return } 89 | scrollPosition = .init(x: newValue.x) 90 | }) 91 | .scrollPosition(id: $selectedTab) 92 | } 93 | if chromeVisible { 94 | HStack { 95 | ForEach(subviews: controls()) { subview in 96 | subview 97 | .frame(maxWidth: .infinity) 98 | } 99 | Button { 100 | withAnimation { 101 | proxy.scrollTo(NewTabFormID.id) 102 | } 103 | } label: { 104 | Image(systemName: "plus") 105 | } 106 | .frame(maxWidth: .infinity) 107 | } 108 | .imageScale(.large) 109 | .transition(.move(edge: .bottom).combined(with: .opacity)) 110 | } 111 | } 112 | .background(.bar) 113 | .frame(maxHeight: chromeVisible ? nil : 0) 114 | } 115 | } 116 | } 117 | } 118 | 119 | struct BrowserTab: View { 120 | let value: ID 121 | @ViewBuilder let content: () -> Content 122 | @ViewBuilder let label: () -> Label 123 | 124 | var body: some View { 125 | content() 126 | .containerValue(\.browserTabLabel, AnyView(label())) 127 | .containerValue(\.browserTabValue, value) 128 | } 129 | } 130 | 131 | extension ContainerValues { 132 | @Entry var browserTabLabel: AnyView? = nil 133 | @Entry var browserTabValue: AnyHashable? = nil 134 | } 135 | 136 | #Preview { 137 | @Previewable @State var selectedTab: Int? = 0 138 | BrowserTabsView(selectedTab: $selectedTab, chromeVisible: true) { 139 | ForEach(0..<10) { id in 140 | BrowserTab(value: id) { 141 | Text("Tab Content \(id)") 142 | .frame(maxWidth: .infinity, maxHeight: .infinity) 143 | .background(.red) 144 | } label: { 145 | HStack { 146 | TextField(text: .constant("Tab \(id)")) { 147 | EmptyView() 148 | } 149 | Button { 150 | 151 | } label: { 152 | Image(systemName: "arrow.clockwise") 153 | } 154 | } 155 | } 156 | } 157 | } newTabForm: { 158 | TextField(text: .constant(""), prompt: Text("New Tab")) { 159 | EmptyView() 160 | } 161 | } newTabView: { 162 | ContentUnavailableView("New Tab", systemImage: "plus.square.fill.on.square.fill") 163 | .containerRelativeFrame(.horizontal) 164 | } tabActions: { 165 | Button { 166 | 167 | } label: { 168 | Text("Close") 169 | } 170 | } controls: { 171 | Button { 172 | 173 | } label: { 174 | Image(systemName: "chevron.left") 175 | } 176 | Button { 177 | 178 | } label: { 179 | Image(systemName: "chevron.right") 180 | } 181 | Button { 182 | 183 | } label: { 184 | Image(systemName: "square.and.arrow.up") 185 | } 186 | Button { 187 | 188 | } label: { 189 | Image(systemName: "book") 190 | } 191 | Button { 192 | 193 | } label: { 194 | Image(systemName: "plus") 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /lib/crane/browser/window.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser.Window do 2 | use GenServer 3 | 4 | alias Crane.{ 5 | Browser, 6 | Fuse 7 | } 8 | alias Crane.Browser.Window.{ 9 | History, 10 | Logger, 11 | WebSocket 12 | } 13 | 14 | import Crane.Utils 15 | 16 | defstruct name: nil, 17 | history: %History{}, 18 | logger: nil, 19 | browser_name: nil, 20 | view_trees: %{ 21 | document: [], 22 | body: [], 23 | loading: [], 24 | disconnecting: [], 25 | reconnecting: [], 26 | error: [] 27 | }, 28 | stylesheets: [], 29 | response: nil, 30 | created_at: nil, 31 | refs: %{} 32 | 33 | def start_link(args) when is_list(args) do 34 | name = generate_name(:window) 35 | GenServer.start_link(__MODULE__, [{:name, name} | args], name: name) 36 | end 37 | 38 | def start_link(state) when is_map(state) do 39 | state = 40 | state 41 | |> Map.get_and_update(:name, fn 42 | invalid when invalid in ["", nil]-> {invalid, generate_name(:window)} 43 | name -> {name, name} 44 | end) 45 | |> elem(1) 46 | |> Map.take([:history, :name, :response, :view_tree, :browser_name]) 47 | 48 | GenServer.start_link(__MODULE__, state, name: state.name) 49 | end 50 | 51 | @impl true 52 | def init(args) when is_list(args) do 53 | window = %__MODULE__{ 54 | name: args[:name], 55 | created_at: DateTime.now!("Etc/UTC"), 56 | browser_name: args[:browser].name 57 | } 58 | {:ok, logger} = Logger.new(window: window) 59 | 60 | Process.flag(:trap_exit, true) 61 | {:ok, %__MODULE__{window | logger: logger}} 62 | end 63 | 64 | def init(state) when is_map(state) do 65 | window = struct(__MODULE__, state) 66 | {:ok, logger} = Logger.new(window: window) 67 | Process.flag(:trap_exit, true) 68 | {:ok, %__MODULE__{window | logger: logger}} 69 | end 70 | 71 | @impl true 72 | def handle_call(:get, _from, window), 73 | do: {:reply, {:ok, window}, window} 74 | 75 | def handle_call({:monitor, pid}, _from, window) do 76 | Process.monitor(pid) 77 | {:reply, window, window} 78 | end 79 | 80 | def handle_call({:fetch, options}, _from, %__MODULE__{} = window) do 81 | options 82 | |> Keyword.validate([url: nil, method: "GET", headers: [], body: nil]) 83 | |> case do 84 | {:ok, options} -> 85 | {:ok, %Browser{} = browser} = Browser.get(window.browser_name) 86 | {_request, response} = 87 | options 88 | |> Keyword.update(:url, nil, &String.trim(&1)) 89 | |> Keyword.put(:headers, browser.headers ++ options[:headers]) 90 | |> Keyword.merge(Application.get_env(:crane, :fetch_req_options, [])) 91 | |> Req.new() 92 | |> HttpCookie.ReqPlugin.attach() 93 | |> Req.Request.merge_options([cookie_jar: browser.cookie_jar]) 94 | |> Req.run!() 95 | 96 | %{private: %{cookie_jar: cookie_jar}} = response 97 | 98 | :ok = Browser.update_cookie_jar(browser, cookie_jar) 99 | 100 | {:reply, {:ok, response, window}, window} 101 | 102 | {:error, invalid_options} -> 103 | {:reply, {:invalid_options, invalid_options}, window} 104 | end 105 | 106 | rescue 107 | error -> 108 | response = %Req.Response{ 109 | status: 400, 110 | body: Exception.message(error) 111 | } 112 | 113 | {:reply, {:ok, response, window}, window} 114 | end 115 | 116 | def handle_call({:visit, options}, from, window) do 117 | case handle_call({:fetch, options}, from, window) do 118 | {:reply, {:ok, response, window}, _window} -> 119 | history = 120 | with {:ok, options} <- Keyword.validate(options, [url: nil, method: "GET", headers: [], body: nil]), 121 | "GET" <- Keyword.get(options, :method), 122 | {:ok, _frame, history} <- History.push_state(window.history, %{}, options) do 123 | history 124 | else 125 | _error -> window.history 126 | end 127 | 128 | window = 129 | %{window | history: history, response: response} 130 | |> Map.merge(Fuse.run_middleware(:visit, response)) 131 | broadcast(Crane, {:update, window}) 132 | 133 | {:reply, {:ok, response, window}, window} 134 | 135 | error -> error 136 | end 137 | end 138 | 139 | def handle_call({:go, offset}, from, %__MODULE__{history: history} = window) do 140 | with {:ok, {_state, options} = _frame, history} <- History.go(history, offset), 141 | {:reply, {:ok, response, window}, _window} <- handle_call({:fetch, options}, from, %__MODULE__{window | history: history}), 142 | {:ok, options} <- Keyword.validate(options, [url: nil, method: "GET", headers: [], body: nil]), 143 | "GET" <- Keyword.get(options, :method) do 144 | window = %__MODULE__{window | response: response} 145 | {:reply, {:ok, response, window}, window} 146 | else 147 | error -> error 148 | end 149 | end 150 | 151 | def handle_call({:new_socket, options}, _from, %__MODULE__{refs: refs} = window) do 152 | with {:ok, options} <- Keyword.validate(options, [url: nil, headers: [], window_name: nil, receiver: nil]), 153 | {_, options} <- normalize_options(options), 154 | {:ok, socket} <- WebSocket.new(window, options) do 155 | refs = monitor(socket, refs) 156 | window = %__MODULE__{window | refs: refs} 157 | broadcast(Crane, {:new_socket, window, socket}) 158 | 159 | {:reply, {:ok, socket, window}, window} 160 | else 161 | {:error, error} -> 162 | {:reply, error, window} 163 | error -> 164 | {:reply, error, window} 165 | end 166 | end 167 | 168 | def handle_call(:sockets, _from, %__MODULE__{refs: refs} = window) do 169 | sockets = get_reference_resource(refs, :socket, fn(name) -> 170 | WebSocket.get(name) 171 | end) 172 | |> Enum.sort_by(&(&1.created_at), {:asc, DateTime}) 173 | 174 | {:reply, {:ok, sockets}, window} 175 | end 176 | 177 | defp normalize_options(options) do 178 | Keyword.get_and_update(options, :url, fn 179 | "localhost" <> _tail = url -> {url, "http://" <> url} 180 | url -> {url, url} 181 | end) 182 | end 183 | 184 | @impl true 185 | def handle_info({:DOWN, ref, :process, _pid, _reason}, %__MODULE__{refs: refs} = window) do 186 | case Map.pop(refs, ref) do 187 | {nil, _refs} -> 188 | {:noreply, window} 189 | {_name, refs} -> 190 | {:noreply, %__MODULE__{window | refs: refs}} 191 | end 192 | end 193 | 194 | def handle_info(_msg, state) do 195 | {:noreply, state} 196 | end 197 | 198 | def new(args) when is_list(args) do 199 | with {:ok, pid} <- start_link(args), 200 | {:ok, window} <- GenServer.call(pid, :get) do 201 | {:ok, window} 202 | else 203 | error -> {:error, error} 204 | end 205 | end 206 | 207 | def restore(%__MODULE__{} = state) do 208 | with {:ok, pid} <- start_link(state), 209 | {:ok, window} <- GenServer.call(pid, :get) do 210 | {:ok, window} 211 | else 212 | error -> {:error, error} 213 | end 214 | end 215 | 216 | def close(%__MODULE__{name: name}) do 217 | GenServer.stop(name, :normal) 218 | end 219 | 220 | def get(%__MODULE__{name: name}), 221 | do: get(name) 222 | 223 | def get(name) when is_binary(name), 224 | do: get(String.to_existing_atom(name)) 225 | 226 | def get(name) when is_atom(name) do 227 | GenServer.call(name, :get) 228 | end 229 | 230 | def get!(resource_or_name) do 231 | {:ok, window} = get(resource_or_name) 232 | window 233 | end 234 | 235 | def fetch(%__MODULE__{name: name}, options) do 236 | GenServer.call(name, {:fetch, options}) 237 | end 238 | 239 | def visit(%__MODULE__{name: name}, options) do 240 | GenServer.call(name, {:visit, options}) 241 | end 242 | 243 | def forward(%__MODULE__{name: name}) do 244 | GenServer.call(name, {:go, 1}, :infinity) 245 | end 246 | 247 | def back(%__MODULE__{name: name}) do 248 | GenServer.call(name, {:go, -1}) 249 | end 250 | 251 | def go(%__MODULE__{name: name}, offset) do 252 | GenServer.call(name, {:go, offset}) 253 | end 254 | 255 | def new_socket(%__MODULE__{name: name}, options) do 256 | options = Keyword.put(options, :window_name, name) 257 | GenServer.call(name, {:new_socket, options}) 258 | end 259 | 260 | def sockets(%__MODULE__{name: name}) do 261 | GenServer.call(name, :sockets) 262 | end 263 | 264 | def sockets!(window) do 265 | {:ok, sockets} = sockets(window) 266 | sockets 267 | end 268 | end 269 | -------------------------------------------------------------------------------- /lib/crane/phoenix/live/console.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Phoenix.Live.Console do 2 | use Phoenix.LiveView, 3 | layout: {Crane.Phoenix.Layout, :console} 4 | 5 | alias Crane.Phoenix.Live.Console.{ 6 | BrowserState, 7 | WindowState 8 | } 9 | 10 | alias Crane.{ 11 | Browser, 12 | Browser.Window 13 | } 14 | 15 | import Crane.Utils 16 | 17 | def mount(_parms, _session, socket) do 18 | {:ok, browsers} = Crane.browsers() 19 | 20 | subscribe(Crane) 21 | 22 | socket = 23 | socket 24 | |> assign( 25 | page_title: "Crane Console", 26 | refs: %{}, 27 | browsers: browsers, 28 | dark_theme: true, 29 | active_browser: Enum.at(browsers, 0), 30 | browser_states: BrowserState.build(browsers)) 31 | |> render_with(&Crane.Phoenix.Live.Console.HTML.render/1) 32 | {:ok, socket} 33 | end 34 | 35 | def handle_event("new_browser", _params, socket) do 36 | {:ok, browser, _crane} = Crane.new_browser() 37 | 38 | subscribe(browser) 39 | 40 | {:noreply, assign(socket, 41 | browsers: Crane.browsers!(), 42 | active_browser: browser) 43 | } 44 | end 45 | 46 | def handle_event("new_window", _params, socket) do 47 | {:ok, window, _browser} = Crane.Browser.new_window(socket.assigns.active_browser) 48 | 49 | subscribe(window) 50 | 51 | {:noreply, update_active_browser_state(socket, active_window: window)} 52 | end 53 | 54 | def handle_event("active_browser", %{"browser" => browser_name}, socket) do 55 | {:ok, browser} = Crane.Browser.get(browser_name) 56 | 57 | {:noreply, assign(socket, active_browser: browser)} 58 | end 59 | 60 | def handle_event("active_window", %{"window" => window_name}, socket) do 61 | {:ok, window} = Window.get(window_name) 62 | 63 | {:noreply, update_active_browser_state(socket, active_window: window)} 64 | end 65 | 66 | def handle_event("close_browser", %{"browser" => browser_name}, socket) do 67 | {:ok, browser} = Crane.Browser.get(browser_name) 68 | {:ok, _crane} = Crane.close_browser(browser) 69 | 70 | {:noreply, socket} 71 | end 72 | 73 | def handle_event("close_window", %{"window" => window_name}, socket) do 74 | {:ok, browser} = Browser.get(socket.assigns.active_browser) 75 | {:ok, window} = Window.get(window_name) 76 | {:ok, _browser} = Crane.Browser.close_window(browser, window) 77 | 78 | {:noreply, socket} 79 | end 80 | 81 | def handle_event("set_active_tab", %{"tab" => tab}, socket) do 82 | {:noreply, update_active_window_state(socket, active_tab: tab)} 83 | end 84 | 85 | def handle_event("toggle_theme", _parmas, socket) do 86 | {:noreply, update(socket, :dark_theme, &(!&1))} 87 | end 88 | 89 | def handle_info({:DOWN, ref, _, _, _}, socket) do 90 | name = Map.get(socket.assigns.refs, ref) 91 | 92 | refs = demonitor(ref, socket.assigns.refs) 93 | 94 | socket = 95 | socket 96 | |> down(name) 97 | |> assign(refs: refs) 98 | 99 | {:noreply, socket} 100 | end 101 | 102 | def handle_info({:new_browser, browser}, socket) do 103 | refs = monitor(browser, socket.assigns.refs) 104 | 105 | browser_states = Map.put(socket.assigns.browser_states, browser.name, %BrowserState{}) 106 | 107 | browsers = Crane.browsers!() 108 | 109 | active_browser = case browsers do 110 | [browser] -> browser 111 | _browsers -> socket.assigns.active_browser 112 | end 113 | 114 | {:noreply, assign(socket, 115 | refs: refs, 116 | active_browser: active_browser, 117 | browsers: Crane.browsers!(), 118 | browser_states: browser_states) 119 | } 120 | end 121 | 122 | def handle_info({:new_window, window, browser}, socket) do 123 | refs = monitor(window, socket.assigns.refs) 124 | 125 | window_states = 126 | socket.assigns.browser_states 127 | |> get_in([window.browser_name, :window_states]) 128 | |> Map.put(window.name, %WindowState{}) 129 | 130 | browser_state = socket.assigns.browser_states[browser.name] 131 | 132 | browsers = Crane.browsers!() 133 | windows = Browser.windows!(browser) 134 | 135 | active_window = case windows do 136 | [window] -> window 137 | _windows -> browser_state.active_window 138 | end 139 | 140 | socket = 141 | socket 142 | |> update_browser_state(browser, active_window: active_window, window_states: window_states) 143 | |> assign( 144 | active_browser: update_if_active(browser, socket.assigns.active_browser), 145 | browsers: browsers, 146 | refs: refs) 147 | 148 | {:noreply, socket} 149 | end 150 | 151 | def handle_info({:update, %Window{} = window}, socket) do 152 | browser = socket.assigns.active_browser 153 | active_window = socket.assigns.browser_states[browser.name].active_window 154 | 155 | socket = if active_window.name == window.name do 156 | update_browser_state(socket, browser, active_window: update_if_active(window, active_window)) 157 | else 158 | socket 159 | end 160 | 161 | {:noreply, socket} 162 | end 163 | 164 | def handle_info(_msg, socket) do 165 | {:noreply, socket} 166 | end 167 | 168 | defp down(socket, name) when is_atom(name) do 169 | down(socket, Atom.to_string(name)) 170 | end 171 | 172 | defp down(socket, "browser-" <> _id = name) do 173 | browser_name = String.to_existing_atom(name) 174 | active_browser = new_active(socket.assigns.browsers, browser_name, socket.assigns.active_browser) 175 | browsers = Enum.reject(socket.assigns.browsers, &(browser_name == &1.name)) 176 | 177 | socket 178 | |> delete_browser_state(browser_name) 179 | |> assign(active_browser: active_browser, browsers: browsers) 180 | end 181 | 182 | defp down(socket, "window-" <> _id = name) do 183 | window_name = String.to_existing_atom(name) 184 | 185 | Enum.find(socket.assigns.browser_states, fn({_name, browser_state}) -> 186 | window_name in Map.keys(browser_state.window_states) 187 | end) 188 | |> case do 189 | {browser_name, _browser_states} -> 190 | {:ok, %Browser{name: browser_name} = browser} = Browser.get(browser_name) 191 | {:ok, windows} = Browser.windows(browser) 192 | 193 | active_window = get_in(socket.assigns, [:browser_states, browser.name, :active_window]) 194 | 195 | browsers = Enum.map(socket.assigns.browsers, fn 196 | %Crane.Browser{name: ^browser_name} -> browser 197 | other -> other 198 | end) 199 | 200 | socket 201 | |> delete_window_state(window_name) 202 | |> update_active_browser_state(active_window: new_active(windows, window_name, active_window)) 203 | |> assign(active_browser: browser, browsers: browsers) 204 | _other -> socket 205 | end 206 | end 207 | 208 | defp delete_browser_state(socket, browser_name) do 209 | {_window_state, browser_states} = pop_in(socket.assigns.browser_states, [browser_name]) 210 | 211 | assign(socket, browser_states: browser_states) 212 | end 213 | 214 | defp delete_window_state(socket, window_name) do 215 | browser = socket.assigns.active_browser 216 | {_window_state, browser_states} = pop_in(socket.assigns.browser_states, [browser.name, :window_states, window_name]) 217 | 218 | assign(socket, browser_states: browser_states) 219 | end 220 | 221 | defp update_browser_state(socket, %Browser{name: name}, state_update) do 222 | state_update = Map.new(state_update) 223 | browser_state = Map.merge(socket.assigns.browser_states[name], state_update) 224 | browser_states = Map.put(socket.assigns.browser_states, name, browser_state) 225 | 226 | assign(socket, browser_states: browser_states) 227 | end 228 | 229 | defp update_active_browser_state(socket, state_update) do 230 | state_update = Map.new(state_update) 231 | browser = socket.assigns.active_browser 232 | browser_state = Map.merge(socket.assigns.browser_states[browser.name], state_update) 233 | browser_states = Map.put(socket.assigns.browser_states, browser.name, browser_state) 234 | 235 | assign(socket, browser_states: browser_states) 236 | end 237 | 238 | defp update_active_window_state(socket, state_update) do 239 | state_update = Map.new(state_update) 240 | browser = socket.assigns.active_browser 241 | browser_state = socket.assigns.browser_states[browser.name] 242 | window = browser_state.active_window 243 | window_state = Map.merge(browser_state.window_states[window.name], state_update) 244 | window_states = Map.put(browser_state.window_states, window.name, window_state) 245 | browser_state = Map.put(browser_state, :window_states, window_states) 246 | browser_states = Map.put(socket.assigns.browser_states, browser.name, browser_state) 247 | 248 | assign(socket, browser_states: browser_states) 249 | end 250 | 251 | defp update_if_active(%{name: name} = new_resource, %{name: name} = _active), 252 | do: new_resource 253 | defp update_if_active(_new_resource, nil), 254 | do: nil 255 | defp update_if_active(_new_resource, active), 256 | do: active 257 | 258 | defp new_active([], _name, _active), 259 | do: nil 260 | defp new_active(_list, _name, nil), 261 | do: nil 262 | 263 | defp new_active(list, name, %{name: name}) do 264 | idx = Enum.find_index(list, &(name == &1.name)) 265 | length = length(list) 266 | 267 | if idx == length - 1 do 268 | idx = idx - 1 269 | if idx < 0 do 270 | nil 271 | else 272 | Enum.at(list, idx, nil) 273 | end 274 | else 275 | Enum.at(list, idx + 1, nil) 276 | end 277 | end 278 | 279 | defp new_active(_list, _name, active), 280 | do: active 281 | end 282 | 283 | -------------------------------------------------------------------------------- /test/crane/browser/window_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Crane.Browser.WindowTest do 2 | use ExUnit.Case 3 | alias Plug.Conn 4 | 5 | alias Crane.{ 6 | Browser, 7 | Browser.Window, 8 | Browser.Window.WebSocket 9 | } 10 | 11 | import Crane.Test.Utils 12 | 13 | setup config do 14 | Application.put_env(:crane, :pubsub, config.test) 15 | start_pubsub(config) 16 | {:ok, browser} = Browser.new() 17 | 18 | on_exit fn -> 19 | Application.delete_env(:crane, :pubsub) 20 | end 21 | 22 | {:ok, browser: browser} 23 | end 24 | 25 | describe "new" do 26 | test "will spawn a new Window process", %{browser: browser} do 27 | {:ok, %Window{name: name}} = Window.new(browser: browser) 28 | 29 | refute is_nil(Process.whereis(name)) 30 | end 31 | end 32 | 33 | describe "get" do 34 | setup do 35 | {:ok, browser} = Browser.new() 36 | {:ok, window_pid} = Window.start_link(browser: browser) 37 | {:ok, window} = GenServer.call(window_pid, :get) 38 | 39 | {:ok, window: window} 40 | end 41 | 42 | test "will return the window struct", %{window: window} do 43 | {:ok, %Window{} = got_window} = Window.get(%Window{name: window.name}) 44 | assert window == got_window 45 | end 46 | 47 | test "bang value will return without ok", %{window: window} do 48 | got_window = Window.get!(%Window{name: window.name}) 49 | assert window == got_window 50 | end 51 | end 52 | 53 | describe "close" do 54 | test "will close a Window process", %{browser: browser} do 55 | {:ok, window} = Window.new(browser: browser) 56 | 57 | pid = Process.whereis(window.name) 58 | 59 | assert Process.alive?(pid) 60 | 61 | :ok = Window.close(window) 62 | 63 | refute Process.alive?(pid) 64 | end 65 | 66 | test "when window closes all sockets are closed too", %{browser: browser} do 67 | {:ok, window} = Window.new(browser: browser) 68 | 69 | {:ok, %WebSocket{} = socket_1, window} = Window.new_socket(window, url: "http://localhost:4567/websocket") 70 | {:ok, %WebSocket{} = socket_2, window} = Window.new_socket(window, url: "http://localhost:4567/websocket") 71 | 72 | socket_1_pid = Process.whereis(socket_1.name) 73 | socket_2_pid = Process.whereis(socket_2.name) 74 | 75 | :ok = Window.close(window) 76 | 77 | :timer.sleep(10) 78 | 79 | refute Process.alive?(socket_1_pid) 80 | refute Process.alive?(socket_2_pid) 81 | end 82 | end 83 | 84 | describe "visit" do 85 | setup %{browser: browser} do 86 | {:ok, pid} = Window.start_link(%{browser_name: browser.name}) 87 | 88 | {:ok, window} = GenServer.call(pid, :get) 89 | 90 | {:ok, window: window} 91 | end 92 | 93 | test "with url", %{window: window} do 94 | Req.Test.stub(Window, fn(conn) -> 95 | Plug.Conn.send_resp(conn, conn.status || 200, "Success!") 96 | end) 97 | 98 | Req.Test.allow(Window, self(), pid_for(window)) 99 | 100 | {:ok, response, window} = Window.visit(window, url: "https://dockyard.com") 101 | 102 | assert response == window.response 103 | assert response.body == "Success!" 104 | assert window.history.index == 0 105 | assert window.history.stack == [ 106 | {%{}, headers: [], method: "GET", url: "https://dockyard.com"} 107 | ] 108 | end 109 | 110 | test "updates cookie jar when cookies are sent back", %{window: window} do 111 | url = "https://dockyard.com" 112 | 113 | Req.Test.stub(Window, fn(conn) -> 114 | conn 115 | |> Conn.put_resp_cookie("session-id", "123456") 116 | |> Conn.send_resp(200, "Success!") 117 | end) 118 | 119 | Req.Test.allow(Window, self(), pid_for(window)) 120 | {:ok, _response, _window} = Window.visit(window, url: url) 121 | {:ok, browser} = Browser.get(window.browser_name) 122 | {:ok, cookie, _cookie_jar} = HttpCookie.Jar.get_cookie_header_value(browser.cookie_jar, URI.new!(url)) 123 | 124 | assert cookie == "session-id=123456" 125 | end 126 | end 127 | 128 | describe "fetch" do 129 | setup %{browser: browser} do 130 | {:ok, window} = Window.new(browser: browser) 131 | 132 | {:ok, window: window} 133 | end 134 | 135 | test "with url", %{window: window} do 136 | Req.Test.stub(Window, fn(conn) -> 137 | Plug.Conn.send_resp(conn, conn.status || 200, "Success!") 138 | end) 139 | 140 | Req.Test.allow(Window, self(), pid_for(window)) 141 | 142 | {:ok, response, window} = Window.fetch(window, url: "https://dockyard.com") 143 | 144 | assert window.response == nil 145 | assert response.body == "Success!" 146 | assert window.history.index == -1 147 | assert window.history.stack == [] 148 | end 149 | 150 | test "updates browser cookie jar when cookies are sent back", %{browser: browser, window: window} do 151 | url = "https://dockyard.com" 152 | 153 | Req.Test.stub(Window, fn(conn) -> 154 | conn 155 | |> Conn.put_resp_cookie("session-id", "123456") 156 | |> Conn.send_resp(200, "Success!") 157 | end) 158 | 159 | Req.Test.allow(Window, self(), pid_for(window)) 160 | 161 | {:ok, _response, _window} = Window.fetch(window, url: url) 162 | {:ok, browser} = Browser.get(browser.name) 163 | {:ok, cookie, _cookie_jar} = HttpCookie.Jar.get_cookie_header_value(browser.cookie_jar, URI.new!(url)) 164 | 165 | assert cookie == "session-id=123456" 166 | end 167 | end 168 | 169 | describe "forward/back/go" do 170 | setup %{browser: browser} do 171 | {:ok, window} = Window.new(browser: browser) 172 | 173 | Req.Test.stub(Window, fn(conn) -> 174 | case Conn.request_url(conn) do 175 | "https://dockyard.com/1" -> 176 | Conn.send_resp(conn, 200, "1") 177 | "https://dockyard.com/2" -> 178 | Conn.send_resp(conn, 200, "2") 179 | "https://dockyard.com/3" -> 180 | Conn.send_resp(conn, 200, "3") 181 | "https://dockyard.com/4" -> 182 | Conn.send_resp(conn, 200, "4") 183 | "https://dockyard.com/5" -> 184 | Conn.send_resp(conn, 200, "5") 185 | end 186 | end) 187 | 188 | Req.Test.allow(Window, self(), pid_for(window)) 189 | 190 | {:ok, _response, window} = Window.visit(window, url: "https://dockyard.com/1") 191 | {:ok, _response, window} = Window.visit(window, url: "https://dockyard.com/2") 192 | {:ok, _response, window} = Window.visit(window, url: "https://dockyard.com/3") 193 | {:ok, _response, window} = Window.visit(window, url: "https://dockyard.com/4") 194 | {:ok, _response, window} = Window.visit(window, url: "https://dockyard.com/5") 195 | 196 | {:ok, window: window} 197 | end 198 | 199 | test "will navigate history", %{window: window} do 200 | {:ok, _response, window} = Window.back(window) 201 | assert window.response.body == "4" 202 | {:ok, _response, window} = Window.back(window) 203 | assert window.response.body == "3" 204 | 205 | {:ok, _response, window} = Window.go(window, 2) 206 | assert window.response.body == "5" 207 | {:ok, _response, window} = Window.go(window, -4) 208 | assert window.response.body == "1" 209 | 210 | {:ok, _response, window} = Window.forward(window) 211 | assert window.response.body == "2" 212 | {:ok, _response, window} = Window.forward(window) 213 | assert window.response.body == "3" 214 | end 215 | end 216 | 217 | describe "restore" do 218 | test "will restore a previously closed window state", %{browser: browser} do 219 | {:ok, window} = Window.new(browser: browser) 220 | 221 | Req.Test.stub(Window, fn(conn) -> 222 | Plug.Conn.send_resp(conn, conn.status || 200, "Success!") 223 | end) 224 | 225 | Req.Test.allow(Window, self(), pid_for(window)) 226 | 227 | {:ok, _response, window} = Window.visit(window, url: "https://dockyard.com") 228 | old_pid = Process.whereis(window.name) 229 | 230 | :ok = Window.close(window) 231 | 232 | {:ok, restored_window} = Window.restore(window) 233 | 234 | assert window.name == restored_window.name 235 | assert window.history == restored_window.history 236 | assert window.response == restored_window.response 237 | # assert window.view_trees == restored_window.view_trees 238 | 239 | refute old_pid == Process.whereis(restored_window.name) 240 | end 241 | end 242 | 243 | describe "sockets" do 244 | setup do 245 | {:ok, pid} = Window.start_link(%{}) 246 | {:ok, window} = GenServer.call(pid, :get) 247 | {:ok, window: window} 248 | end 249 | 250 | test "will return all sockets in a tuple", %{window: window} do 251 | {:ok, %WebSocket{} = socket_1, window} = Window.new_socket(window, url: "http://localhost:4567/websocket") 252 | {:ok, %WebSocket{} = socket_2, window} = Window.new_socket(window, url: "http://localhost:4567/websocket") 253 | 254 | {:ok, sockets} = Window.sockets(window) 255 | 256 | assert socket_1 in sockets 257 | assert socket_2 in sockets 258 | end 259 | 260 | test "will return all sockets", %{window: window} do 261 | {:ok, %WebSocket{} = socket_1, window} = Window.new_socket(window, url: "http://localhost:4567/websocket") 262 | {:ok, %WebSocket{} = socket_2, window} = Window.new_socket(window, url: "http://localhost:4567/websocket") 263 | 264 | sockets = Window.sockets!(window) 265 | 266 | assert socket_1 in sockets 267 | assert socket_2 in sockets 268 | end 269 | 270 | test "will spawn a new socket for the window that is monitored by the window", %{window: window} do 271 | {:ok, %WebSocket{} = socket, window} = Window.new_socket(window, url: "http://localhost:4567/websocket") 272 | {:ok, window} = Window.get(window) 273 | 274 | assert socket.name in Map.values(window.refs) 275 | 276 | pid = Process.whereis(socket.name) 277 | Process.exit(pid, :kill) 278 | 279 | :timer.sleep(10) 280 | 281 | {:ok, window} = Window.get(window) 282 | 283 | refute socket.name in Map.values(window.refs) 284 | end 285 | end 286 | end 287 | -------------------------------------------------------------------------------- /Sources/Crane/Crane.swift: -------------------------------------------------------------------------------- 1 | // import ArgumentParser 2 | import Foundation 3 | 4 | import GRPCCore 5 | import GRPCNIOTransportHTTP2 6 | import GRPCProtobuf 7 | 8 | @preconcurrency import LiveViewNativeCore 9 | 10 | @Observable 11 | public final class Crane: @unchecked Sendable { 12 | let client: GRPCClient 13 | let browserService: CraneBrowserService.Client 14 | let windowService: CraneWindowService.Client 15 | var runloop: Task? 16 | 17 | public var windows = [Window]() 18 | 19 | @Observable 20 | public final class Window: @unchecked Sendable { 21 | public let window: CraneWindow 22 | public var url: URL 23 | public var documents: [Int32:Document] 24 | public var stylesheets: [String] 25 | public var history: CraneHistory 26 | 27 | public var canGoBack: Bool { 28 | history.index > -1 29 | } 30 | 31 | public var canGoForward: Bool { 32 | history.index < history.stack.index(before: history.stack.endIndex) 33 | } 34 | 35 | init( 36 | window: CraneWindow, 37 | url: URL, 38 | documents: [Int32: Document], 39 | stylesheets: [String], 40 | history: CraneHistory 41 | ) { 42 | self.window = window 43 | self.url = url 44 | self.documents = documents 45 | self.stylesheets = stylesheets 46 | self.history = history 47 | } 48 | } 49 | 50 | public init() { 51 | self.client = try! GRPCClient(transport: .http2NIOPosix( 52 | target: .ipv4(host: "127.0.0.1", port: 50051), 53 | transportSecurity: .plaintext 54 | )) 55 | self.browserService = CraneBrowserService.Client(wrapping: self.client) 56 | self.windowService = CraneWindowService.Client(wrapping: client) 57 | self.runloop = Task { [weak client] in 58 | while true { 59 | do { 60 | print("connecting") 61 | let start = Date.now 62 | print(start) 63 | try await client?.runConnections() 64 | print("\(start.distance(to: .now))s") 65 | return 66 | } catch { 67 | print(error) 68 | print("retrying") 69 | } 70 | } 71 | } 72 | } 73 | 74 | @discardableResult 75 | public func newWindow(url: URL) async throws -> Window { 76 | let craneWindow = try await windowService.new(CraneWindow()) 77 | 78 | var request = CraneRequest() 79 | request.url = url.absoluteString 80 | request.windowName = craneWindow.name 81 | request.method = "GET" 82 | 83 | var header = CraneHeader() 84 | header.name = "Accept" 85 | header.value = "application/swiftui" 86 | request.headers = [ 87 | header 88 | ] 89 | 90 | let response = try await windowService.visit(request) 91 | let document = response.viewTrees["body"]! 92 | let window = Window( 93 | window: craneWindow, 94 | url: url, 95 | documents: [ 96 | response.history.index: GRPCDocument( 97 | document: document 98 | ) 99 | ], 100 | stylesheets: response.stylesheets, 101 | history: response.history 102 | ) 103 | self.windows.append(window) 104 | return window 105 | } 106 | 107 | @discardableResult 108 | public func refresh(window: Window) async throws -> Window { 109 | let response = try await windowService.refresh(window.window) 110 | let document = response.viewTrees["body"]! 111 | window.documents[response.history.index] = GRPCDocument( 112 | document: document 113 | ) 114 | window.stylesheets = response.stylesheets 115 | window.history = response.history 116 | print("reloaded") 117 | return window 118 | } 119 | 120 | @discardableResult 121 | public func navigate(window: Window, to url: URL) async throws -> Window { 122 | var request = CraneRequest() 123 | request.url = url.absoluteString 124 | request.windowName = window.window.name 125 | request.method = "GET" 126 | 127 | var header = CraneHeader() 128 | header.name = "Accept" 129 | header.value = "application/swiftui" 130 | request.headers = [ 131 | header 132 | ] 133 | 134 | let response = try await windowService.visit(request) 135 | let document = response.viewTrees["body"]! 136 | window.url = url 137 | window.documents[response.history.index] = GRPCDocument( 138 | document: document 139 | ) 140 | window.stylesheets = response.stylesheets 141 | window.history = response.history 142 | return window 143 | } 144 | 145 | @discardableResult 146 | public func back(window: Window) async throws -> Window { 147 | let response = try await windowService.back(window.window) 148 | let document = response.viewTrees["body"]! 149 | window.documents[response.history.index] = GRPCDocument( 150 | document: document 151 | ) 152 | window.stylesheets = response.stylesheets 153 | window.history = response.history 154 | window.url = URL(string: response.history.stack[Int(response.history.index)].url)! 155 | return window 156 | } 157 | 158 | @discardableResult 159 | public func forward(window: Window) async throws -> Window { 160 | let response = try await windowService.forward(window.window) 161 | let document = response.viewTrees["body"]! 162 | window.documents[response.history.index] = GRPCDocument( 163 | document: document 164 | ) 165 | window.stylesheets = response.stylesheets 166 | window.history = response.history 167 | window.url = URL(string: response.history.stack[Int(response.history.index)].url)! 168 | return window 169 | } 170 | 171 | public func close(window: Window) async throws { 172 | let response = try await windowService.close(window.window) 173 | self.windows.removeAll(where: { $0.window.name == window.window.name }) 174 | } 175 | 176 | deinit { 177 | runloop?.cancel() 178 | } 179 | } 180 | 181 | final class CraneNodeRef: LiveViewNativeCore.NodeRef { 182 | let value: Int32 183 | 184 | required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { 185 | fatalError("cannot init from a pointer") 186 | } 187 | 188 | init(_ value: Int32) { 189 | self.value = value 190 | super.init(noPointer: .init()) 191 | } 192 | 193 | override func ref() -> Int32 { 194 | value 195 | } 196 | } 197 | 198 | final class GRPCDocument: LiveViewNativeCore.Document { 199 | var rootNode: GRPCNode! 200 | var nodes = [Int32:GRPCNode]() 201 | 202 | required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { 203 | fatalError("cannot init from a pointer") 204 | } 205 | 206 | init(document: CraneDocument) { 207 | super.init(noPointer: .init()) 208 | var rootNode = CraneNode() 209 | rootNode.type = "root" 210 | rootNode.id = -1 211 | rootNode.children = document.nodes 212 | self.rootNode = GRPCNode(rootNode, id: rootNode.id, parentID: nil, document: self) 213 | nodes[self.rootNode.grpcNode.id] = self.rootNode 214 | var unhandledNodes = [(rootNode, parent: Int32(-2))] 215 | while let (node, parent) = unhandledNodes.popLast() { 216 | let grpcNode = GRPCNode(node, id: node.id, parentID: parent, document: self) 217 | nodes[node.id] = grpcNode 218 | if parent >= -1 { 219 | self.nodes[parent]!.childrenIDs.append(grpcNode.nodeId()) 220 | } 221 | unhandledNodes.insert(contentsOf: node.children.reversed().map { ($0, parent: node.id) }, at: 0) 222 | } 223 | } 224 | 225 | override func children(_ nodeRef: NodeRef) -> [NodeRef] { 226 | nodes[nodeRef.ref()]!.childrenIDs 227 | } 228 | 229 | override func get(_ nodeRef: NodeRef) -> NodeData { 230 | nodes[nodeRef.ref()]!.data() 231 | } 232 | 233 | override func getAttributes(_ nodeRef: NodeRef) -> [LiveViewNativeCore.Attribute] { 234 | self.getNode(nodeRef).attributes() 235 | } 236 | 237 | override func getNode(_ nodeRef: NodeRef) -> LiveViewNativeCore.Node { 238 | nodes[nodeRef.ref()]! 239 | } 240 | 241 | override func getParent(_ nodeRef: NodeRef) -> NodeRef? { 242 | nodes[nodeRef.ref()]?.parentID 243 | } 244 | 245 | override func mergeFragmentJson(_ json: String) throws { 246 | fatalError("diff merging is not supported") 247 | } 248 | 249 | override func nextUploadId() -> UInt64 { 250 | fatalError("Uploads are not supported") 251 | } 252 | 253 | override func render() -> String { 254 | """ 255 | render document to string 256 | """ 257 | } 258 | 259 | override func root() -> NodeRef { 260 | rootNode.nodeId() 261 | } 262 | 263 | override func setEventHandler(_ handler: DocumentChangeHandler) { 264 | fatalError("document change events are not supported") 265 | } 266 | } 267 | 268 | final class GRPCNode: LiveViewNativeCore.Node { 269 | let grpcNode: CraneNode 270 | let nodeRef: NodeRef 271 | let parentID: NodeRef? 272 | var childrenIDs: [NodeRef] = [] 273 | unowned var grpcDocument: GRPCDocument 274 | 275 | required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { 276 | fatalError("cannot init from a pointer") 277 | } 278 | 279 | init(_ grpcNode: CraneNode, id: Int32, parentID: Int32?, document: GRPCDocument) { 280 | self.grpcNode = grpcNode 281 | self.nodeRef = CraneNodeRef(id) 282 | self.parentID = parentID.map { CraneNodeRef($0) } 283 | self.grpcDocument = document 284 | super.init(noPointer: .init()) 285 | } 286 | 287 | override func attributes() -> [LiveViewNativeCore.Attribute] { 288 | grpcNode.attributes.map { 289 | LiveViewNativeCore.Attribute(name: AttributeName(rawValue: $0.name)!, value: $0.value) 290 | } 291 | } 292 | 293 | override func data() -> LiveViewNativeCore.NodeData { 294 | switch grpcNode.type { 295 | case "text": 296 | return .leaf(value: grpcNode.textContent.trimmingCharacters(in: .whitespacesAndNewlines)) 297 | case "root": 298 | return .root 299 | default: 300 | return .nodeElement(element: LiveViewNativeCore.Element(name: ElementName(namespace: nil, name: grpcNode.tagName), attributes: attributes())) 301 | } 302 | } 303 | 304 | override func display() -> String { 305 | """ 306 | <\(grpcNode.tagName) /> 307 | """ 308 | } 309 | 310 | override func document() -> LiveViewNativeCore.Document { 311 | self.grpcDocument 312 | } 313 | 314 | override func getAttribute(_ name: AttributeName) -> LiveViewNativeCore.Attribute? { 315 | attributes().first(where: { $0.name == name }) 316 | } 317 | 318 | override func getChildren() -> [LiveViewNativeCore.Node] { 319 | self.childrenIDs.map { 320 | self.grpcDocument.getNode($0) 321 | } 322 | } 323 | 324 | override func getDepthFirstChildren() -> [LiveViewNativeCore.Node] { 325 | var out = [LiveViewNativeCore.Node]() 326 | 327 | for child in self.getChildren() { 328 | out.append(child) 329 | let depth = child.getDepthFirstChildren() 330 | out.append(contentsOf: depth) 331 | } 332 | return out 333 | } 334 | 335 | override func nodeId() -> NodeRef { 336 | nodeRef 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /lib/crane/phoenix/live/console/html.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Phoenix.Live.Console.HTML do 2 | use Phoenix.Component 3 | 4 | alias Crane.Browser 5 | 6 | def render(assigns) do 7 | ~H""" 8 |
12 | <.browser_pane 13 | :let={browser_state} 14 | browser_states={@browser_states} 15 | browsers={@browsers} 16 | active_browser={@active_browser} 17 | > 18 | <.window_pane :let={{window_state, active_window}} browser_state={browser_state} active_browser={@active_browser}> 19 | <.view_tree_pane active_window={active_window}/> 20 |
21 | <.bottom_section window_state={window_state}/> 22 | 23 | 24 |
25 | """ 26 | end 27 | 28 | def browser_pane(assigns) do 29 | browser_state = 30 | if assigns.active_browser do 31 | assigns.browser_states[assigns.active_browser.name] 32 | end 33 | assigns = assign(assigns, :browser_state, browser_state) 34 | 35 | ~H""" 36 |
37 | 45 | 48 |
49 | 50 |
51 | {render_slot(@inner_block, @browser_state)} 52 |
53 | """ 54 | end 55 | 56 | def window_pane(assigns) do 57 | window_state = 58 | if assigns.browser_state.active_window do 59 | get_in(assigns, [:browser_state, :window_states, assigns.browser_state.active_window.name]) 60 | end 61 | windows = Browser.windows!(assigns.active_browser) 62 | assigns = assign(assigns, 63 | windows: windows, 64 | window_state: window_state) 65 | 66 | ~H""" 67 |
68 | 76 | 79 |
80 |
81 | {render_slot(@inner_block, {@window_state, @browser_state.active_window})} 82 |
83 | """ 84 | end 85 | 86 | def view_tree_pane(assigns) do 87 | view_tree = assigns.active_window.view_trees.document 88 | 89 | assigns = assign(assigns, :view_tree, view_tree) 90 | 91 | ~H""" 92 |
93 | <.view_tree_node :for={node <- @view_tree} node={node}/> 94 |
95 | """ 96 | end 97 | 98 | def view_tree_node(%{node: {:text, _attrs, [text]}} = assigns) when is_binary(text) do 99 | assigns = assign(assigns, :text, text) 100 | ~H""" 101 | {@text} 102 | """ 103 | end 104 | 105 | def view_tree_node(assigns) do 106 | ~H""" 107 |
108 | 109 | 110 |

<{node_val(@node, :tag_name)}<.node_attrs node={@node}/>>

</{node_val(@node, :tag_name)}>

111 |
112 | 113 | <{node_val(@node, :tag_name)}<.node_attrs node={@node}/>> 114 | 115 |
116 | <.view_tree_node :for={node <- node_children(@node)} node={node}/> 117 |

</{node_val(@node, :tag_name)}>

118 |
119 | """ 120 | end 121 | 122 | def node_val({tag_name, _attrs, _children}, :tag_name), 123 | do: tag_name 124 | def node_val(node, attr_name) when is_atom(attr_name), 125 | do: node_val(node, Atom.to_string(attr_name)) 126 | def node_val({_tag_name, attrs, _children}, attr_name) do 127 | Enum.find_value(attrs, fn 128 | {^attr_name, value} when is_binary(value) -> String.trim(value) 129 | {^attr_name, value} -> value 130 | _other -> nil 131 | end) 132 | end 133 | 134 | def node_attrs(%{node: {_tag_name, [], _children}} = assigns), 135 | do: ~H"" 136 | def node_attrs(%{node: {_tag_name, attrs, _children}} = assigns) do 137 | attrs = Enum.reduce(attrs, [], fn 138 | {"_id", _value}, attrs -> attrs 139 | {name, value}, attrs -> [attrs, {name, trim(value)}] 140 | end) 141 | 142 | assigns = assign(assigns, :attrs, attrs) 143 | 144 | ~H({name}="{value}") 145 | end 146 | 147 | defp trim(text) when is_binary(text), 148 | do: String.trim(text) 149 | defp trim(value), 150 | do: value 151 | 152 | def node_children({_tag_name, _attrs, children}), 153 | do: children 154 | 155 | def bottom_section(assigns) do 156 | ~H""" 157 |
158 |
159 | 164 |
165 | 166 |
167 | <.display_panel type={@window_state.active_tab}/> 168 |
169 |
170 | """ 171 | end 172 | 173 | def display_panel(%{type: "Logs"} = assigns) do 174 | ~H""" 175 |
176 |
177 | 178 |
179 | 182 | 185 | 188 | 191 | 194 |
195 | 198 |
199 |
200 |
201 |           [14:42:05][Browser 1 > Console] Info: UI Initialized. Ready for interaction.
202 |           [14:42:05][Browser 1 > Console] Debug: Theme set to dark by default. Location: Hingham, Massachusetts, United States. Current time: Monday, April 7, 2025 at 2:42 PM EDT
203 |           [14:42:05][Browser 1 > Console] Warn: No network activity detected yet.
204 |         
205 |
206 |
207 | """ 208 | end 209 | 210 | def display_panel(%{type: "Network"} = assigns) do 211 | ~H""" 212 |
213 |
214 | 217 | 220 | 221 |
222 | 223 | 224 |
225 |
226 | 227 | 228 | 229 | 230 | 231 | 232 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 325 | 326 | 327 |
NameStatusType 233 | Initiator 234 | SizeTimeWaterfall
GET /api/users200 OKfetchapp.js:15015.3 KB120 ms 249 |
250 |
254 |
255 |
256 |
GET /assets/styles.css200 OKcssindex.html:1045.1 KB85 ms 266 |
267 |
271 |
272 |
273 |
GET /assets/logo.png200 OKpngstyles.css:58.9 KB55 ms 283 |
284 |
288 |
289 |
290 |
POST /api/login401 Unauthorizedfetchlogin.js:30512 B210 ms 300 |
301 |
305 |
306 |
307 |
GET /assets/app.js304 Not Modifiedjsindex.html:15(disk cache)5 ms 317 |
318 |
322 |
323 |
324 |
328 |
329 |
...
330 |
331 | """ 332 | end 333 | 334 | def display_panel(assigns) do 335 | ~H""" 336 |
Unknown type: {@type}
337 | """ 338 | end 339 | 340 | defp active?(%{name: name}, %{name: name}), 341 | do: true 342 | defp active?(resource, resource), 343 | do: true 344 | defp active?(_active_resource, _resource), 345 | do: false 346 | end 347 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, 3 | "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 4 | "cdpotion": {:hex, :cdpotion, "0.1.4", "819ee3393de714c3b22477be3aac7c4df183cd414c80ade5b6c7071cd0e4e421", [:mix], [], "hexpm", "29eb4ac667a0b816506039cceb4a5dffd68967ff52451235b0120c38f312c63b"}, 5 | "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, 6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 7 | "cowlib": {:hex, :cowlib, "2.14.0", "623791c56c1cc9df54a71a9c55147a401549917f00a2e48a6ae12b812c586ced", [:make, :rebar3], [], "hexpm", "0af652d1550c8411c3b58eed7a035a7fb088c0b86aff6bc504b0bc3b7f791aa2"}, 8 | "elixirkit": {:git, "https://github.com/liveview-native/elixirkit.git", "de095465606bbf408dc44b7969e670db0f1da526", [branch: "main"]}, 9 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 10 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 11 | "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, 12 | "grpc": {:git, "https://github.com/elixir-grpc/grpc.git", "5239816cac1d5eacfeee6dffa2f3df154c4de97c", []}, 13 | "gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"}, 14 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 15 | "http_cookie": {:hex, :http_cookie, "0.7.0", "a46ba9a83390824c4bccecc41e7e2f9ceb0f3e86fbda84b5632f2b625e363930", [:mix], [{:idna, "~> 6.1", [hex: :idna, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}], "hexpm", "3cb72de4dda3e312604713a1e71e3150ea8e71898b613a638538ef2515b66d29"}, 16 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 17 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 18 | "live_debugger": {:hex, :live_debugger, "0.1.4", "8dfd41ab718a00d8e27646aa3e5a89b0b054fb41f226fc73b460dad15685b3d3", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "6938b278e65a1d467407b8a2faa0495797fae65fe3ea3c29b10558ac41dc8600"}, 19 | "live_view_native": {:git, "https://github.com/liveview-native/live_view_native.git", "e759cdcbcd84790c5d8fcf16162c53979db10421", []}, 20 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 21 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 22 | "mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"}, 23 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 24 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 25 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 26 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 27 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, 28 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.9", "4dc5e535832733df68df22f9de168b11c0c74bca65b27b088a10ac36dfb75d04", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1dccb04ec8544340e01608e108f32724458d0ac4b07e551406b3b920c40ba2e5"}, 29 | "phoenix_playground": {:git, "https://github.com/bcardarella/phoenix_playground.git", "ecdd33a22e345488dadc7ecb032414979ba6e082", [branch: "bc-release-compat"]}, 30 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 31 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 32 | "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, 33 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 34 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, 35 | "plug_crypto": {:git, "https://github.com/elixir-plug/plug_crypto.git", "70af9d89e6bcb6fa7c47d42ef608e5c76a50d7ff", [branch: "main"]}, 36 | "protobuf": {:hex, :protobuf, "0.14.1", "9ac0582170df27669ccb2ef6cb0a3d55020d58896edbba330f20d0748881530a", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "39a9d49d346e3ed597e5ae3168a43d9603870fc159419617f584cdf6071f0e25"}, 37 | "protobuf_generate": {:git, "https://github.com/drowzy/protobuf_generate.git", "c8bc913b4288367a6bb722e48f6a8192134573dd", []}, 38 | "public_suffix": {:git, "https://github.com/axelson/publicsuffix-elixir.git", "fa40c243d4b5d8598b90cff268bc4e33f3bb63f1", []}, 39 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 40 | "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, 41 | "sourceror": {:hex, :sourceror, "1.8.2", "f486ddded3b884175583413b431178b691b42d3e616f1ee80bed15503c5f7fd7", [:mix], [], "hexpm", "3f3126d50c222e1029c31861165e73e9c89ebcd543d2896192e2c1d792688fef"}, 42 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 43 | "test_server": {:hex, :test_server, "0.1.20", "b71a33ef259fec8829460d1f69d8b68a340373b8e635d9e478743f2bc73367c7", [:mix], [{:bandit, ">= 1.4.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 2.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:x509, "~> 0.6", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "28801f6ade12711f24f83b7779109ce2c223ee3c21c21604797caee69cff0e42"}, 44 | "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, 45 | "thousand_island": {:hex, :thousand_island, "1.3.12", "590ff651a6d2a59ed7eabea398021749bdc664e2da33e0355e6c64e7e1a2ef93", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55d0b1c868b513a7225892b8a8af0234d7c8981a51b0740369f3125f7c99a549"}, 46 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 47 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 48 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 49 | "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, 50 | "x509": {:hex, :x509, "0.8.10", "5d1ec6d5f4db31982f9dc34e6a1eebd631d04599e0b6c1c259f1dadd4495e11f", [:mix], [], "hexpm", "a191221665af28b9bdfff0c986ef55f80e126d8ce751bbdf6cefa846410140c0"}, 51 | } 52 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 008382EE2D9C7F58001D4CF4 /* LiveViewNative in Frameworks */ = {isa = PBXBuildFile; productRef = 008382ED2D9C7F58001D4CF4 /* LiveViewNative */; }; 11 | 008F5FE72D80E0A800C7171C /* LiveViewNative in Frameworks */ = {isa = PBXBuildFile; productRef = 008F5FE62D80E0A800C7171C /* LiveViewNative */; }; 12 | 00AC344E2D9491E8009E232F /* ElixirKitCrane in Frameworks */ = {isa = PBXBuildFile; productRef = 00AC344D2D9491E8009E232F /* ElixirKitCrane */; }; 13 | /* End PBXBuildFile section */ 14 | 15 | /* Begin PBXFileReference section */ 16 | 008F5FD62D80E04D00C7171C /* CraneDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CraneDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 17 | /* End PBXFileReference section */ 18 | 19 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 20 | 008F5FD82D80E04D00C7171C /* CraneDemo */ = { 21 | isa = PBXFileSystemSynchronizedRootGroup; 22 | path = CraneDemo; 23 | sourceTree = ""; 24 | }; 25 | /* End PBXFileSystemSynchronizedRootGroup section */ 26 | 27 | /* Begin PBXFrameworksBuildPhase section */ 28 | 008F5FD32D80E04D00C7171C /* Frameworks */ = { 29 | isa = PBXFrameworksBuildPhase; 30 | buildActionMask = 2147483647; 31 | files = ( 32 | 008382EE2D9C7F58001D4CF4 /* LiveViewNative in Frameworks */, 33 | 00AC344E2D9491E8009E232F /* ElixirKitCrane in Frameworks */, 34 | 008F5FE72D80E0A800C7171C /* LiveViewNative in Frameworks */, 35 | ); 36 | runOnlyForDeploymentPostprocessing = 0; 37 | }; 38 | /* End PBXFrameworksBuildPhase section */ 39 | 40 | /* Begin PBXGroup section */ 41 | 008F5FCD2D80E04D00C7171C = { 42 | isa = PBXGroup; 43 | children = ( 44 | 008F5FD82D80E04D00C7171C /* CraneDemo */, 45 | 00AC344C2D9491E8009E232F /* Frameworks */, 46 | 008F5FD72D80E04D00C7171C /* Products */, 47 | ); 48 | sourceTree = ""; 49 | }; 50 | 008F5FD72D80E04D00C7171C /* Products */ = { 51 | isa = PBXGroup; 52 | children = ( 53 | 008F5FD62D80E04D00C7171C /* CraneDemo.app */, 54 | ); 55 | name = Products; 56 | sourceTree = ""; 57 | }; 58 | 00AC344C2D9491E8009E232F /* Frameworks */ = { 59 | isa = PBXGroup; 60 | children = ( 61 | ); 62 | name = Frameworks; 63 | sourceTree = ""; 64 | }; 65 | /* End PBXGroup section */ 66 | 67 | /* Begin PBXNativeTarget section */ 68 | 008F5FD52D80E04D00C7171C /* CraneDemo */ = { 69 | isa = PBXNativeTarget; 70 | buildConfigurationList = 008F5FE22D80E05000C7171C /* Build configuration list for PBXNativeTarget "CraneDemo" */; 71 | buildPhases = ( 72 | 008F5FD22D80E04D00C7171C /* Sources */, 73 | 008F5FD32D80E04D00C7171C /* Frameworks */, 74 | 008F5FD42D80E04D00C7171C /* Resources */, 75 | ); 76 | buildRules = ( 77 | ); 78 | dependencies = ( 79 | ); 80 | fileSystemSynchronizedGroups = ( 81 | 008F5FD82D80E04D00C7171C /* CraneDemo */, 82 | ); 83 | name = CraneDemo; 84 | packageProductDependencies = ( 85 | 008F5FE62D80E0A800C7171C /* LiveViewNative */, 86 | 00AC344D2D9491E8009E232F /* ElixirKitCrane */, 87 | 008382ED2D9C7F58001D4CF4 /* LiveViewNative */, 88 | ); 89 | productName = CraneDemo; 90 | productReference = 008F5FD62D80E04D00C7171C /* CraneDemo.app */; 91 | productType = "com.apple.product-type.application"; 92 | }; 93 | /* End PBXNativeTarget section */ 94 | 95 | /* Begin PBXProject section */ 96 | 008F5FCE2D80E04D00C7171C /* Project object */ = { 97 | isa = PBXProject; 98 | attributes = { 99 | BuildIndependentTargetsInParallel = 1; 100 | LastSwiftUpdateCheck = 1630; 101 | LastUpgradeCheck = 1630; 102 | TargetAttributes = { 103 | 008F5FD52D80E04D00C7171C = { 104 | CreatedOnToolsVersion = 16.3; 105 | }; 106 | }; 107 | }; 108 | buildConfigurationList = 008F5FD12D80E04D00C7171C /* Build configuration list for PBXProject "CraneDemo" */; 109 | developmentRegion = en; 110 | hasScannedForEncodings = 0; 111 | knownRegions = ( 112 | en, 113 | Base, 114 | ); 115 | mainGroup = 008F5FCD2D80E04D00C7171C; 116 | minimizedProjectReferenceProxies = 1; 117 | packageReferences = ( 118 | 007A39C92D8C645400597DB4 /* XCLocalSwiftPackageReference "../ElixirKitCrane" */, 119 | 008382EC2D9C7F58001D4CF4 /* XCLocalSwiftPackageReference "../../Packages/liveview-client-swiftui" */, 120 | ); 121 | preferredProjectObjectVersion = 77; 122 | productRefGroup = 008F5FD72D80E04D00C7171C /* Products */; 123 | projectDirPath = ""; 124 | projectRoot = ""; 125 | targets = ( 126 | 008F5FD52D80E04D00C7171C /* CraneDemo */, 127 | ); 128 | }; 129 | /* End PBXProject section */ 130 | 131 | /* Begin PBXResourcesBuildPhase section */ 132 | 008F5FD42D80E04D00C7171C /* Resources */ = { 133 | isa = PBXResourcesBuildPhase; 134 | buildActionMask = 2147483647; 135 | files = ( 136 | ); 137 | runOnlyForDeploymentPostprocessing = 0; 138 | }; 139 | /* End PBXResourcesBuildPhase section */ 140 | 141 | /* Begin PBXSourcesBuildPhase section */ 142 | 008F5FD22D80E04D00C7171C /* Sources */ = { 143 | isa = PBXSourcesBuildPhase; 144 | buildActionMask = 2147483647; 145 | files = ( 146 | ); 147 | runOnlyForDeploymentPostprocessing = 0; 148 | }; 149 | /* End PBXSourcesBuildPhase section */ 150 | 151 | /* Begin XCBuildConfiguration section */ 152 | 008F5FE02D80E05000C7171C /* Debug */ = { 153 | isa = XCBuildConfiguration; 154 | buildSettings = { 155 | ALWAYS_SEARCH_USER_PATHS = NO; 156 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 157 | CLANG_ANALYZER_NONNULL = YES; 158 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 159 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 160 | CLANG_ENABLE_MODULES = YES; 161 | CLANG_ENABLE_OBJC_ARC = YES; 162 | CLANG_ENABLE_OBJC_WEAK = YES; 163 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 164 | CLANG_WARN_BOOL_CONVERSION = YES; 165 | CLANG_WARN_COMMA = YES; 166 | CLANG_WARN_CONSTANT_CONVERSION = YES; 167 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 168 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 169 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 170 | CLANG_WARN_EMPTY_BODY = YES; 171 | CLANG_WARN_ENUM_CONVERSION = YES; 172 | CLANG_WARN_INFINITE_RECURSION = YES; 173 | CLANG_WARN_INT_CONVERSION = YES; 174 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 175 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 176 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 177 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 178 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 179 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 180 | CLANG_WARN_STRICT_PROTOTYPES = YES; 181 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 182 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 183 | CLANG_WARN_UNREACHABLE_CODE = YES; 184 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 185 | COPY_PHASE_STRIP = NO; 186 | DEBUG_INFORMATION_FORMAT = dwarf; 187 | ENABLE_STRICT_OBJC_MSGSEND = YES; 188 | ENABLE_TESTABILITY = YES; 189 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 190 | GCC_C_LANGUAGE_STANDARD = gnu17; 191 | GCC_DYNAMIC_NO_PIC = NO; 192 | GCC_NO_COMMON_BLOCKS = YES; 193 | GCC_OPTIMIZATION_LEVEL = 0; 194 | GCC_PREPROCESSOR_DEFINITIONS = ( 195 | "DEBUG=1", 196 | "$(inherited)", 197 | ); 198 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 199 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 200 | GCC_WARN_UNDECLARED_SELECTOR = YES; 201 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 202 | GCC_WARN_UNUSED_FUNCTION = YES; 203 | GCC_WARN_UNUSED_VARIABLE = YES; 204 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 205 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 206 | MTL_FAST_MATH = YES; 207 | ONLY_ACTIVE_ARCH = YES; 208 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 209 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 210 | }; 211 | name = Debug; 212 | }; 213 | 008F5FE12D80E05000C7171C /* Release */ = { 214 | isa = XCBuildConfiguration; 215 | buildSettings = { 216 | ALWAYS_SEARCH_USER_PATHS = NO; 217 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 218 | CLANG_ANALYZER_NONNULL = YES; 219 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 220 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 221 | CLANG_ENABLE_MODULES = YES; 222 | CLANG_ENABLE_OBJC_ARC = YES; 223 | CLANG_ENABLE_OBJC_WEAK = YES; 224 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 225 | CLANG_WARN_BOOL_CONVERSION = YES; 226 | CLANG_WARN_COMMA = YES; 227 | CLANG_WARN_CONSTANT_CONVERSION = YES; 228 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 229 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 230 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 231 | CLANG_WARN_EMPTY_BODY = YES; 232 | CLANG_WARN_ENUM_CONVERSION = YES; 233 | CLANG_WARN_INFINITE_RECURSION = YES; 234 | CLANG_WARN_INT_CONVERSION = YES; 235 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 236 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 237 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 238 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 239 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 240 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 241 | CLANG_WARN_STRICT_PROTOTYPES = YES; 242 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 243 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 244 | CLANG_WARN_UNREACHABLE_CODE = YES; 245 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 246 | COPY_PHASE_STRIP = NO; 247 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 248 | ENABLE_NS_ASSERTIONS = NO; 249 | ENABLE_STRICT_OBJC_MSGSEND = YES; 250 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 251 | GCC_C_LANGUAGE_STANDARD = gnu17; 252 | GCC_NO_COMMON_BLOCKS = YES; 253 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 254 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 255 | GCC_WARN_UNDECLARED_SELECTOR = YES; 256 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 257 | GCC_WARN_UNUSED_FUNCTION = YES; 258 | GCC_WARN_UNUSED_VARIABLE = YES; 259 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 260 | MTL_ENABLE_DEBUG_INFO = NO; 261 | MTL_FAST_MATH = YES; 262 | SWIFT_COMPILATION_MODE = wholemodule; 263 | }; 264 | name = Release; 265 | }; 266 | 008F5FE32D80E05000C7171C /* Debug */ = { 267 | isa = XCBuildConfiguration; 268 | buildSettings = { 269 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 270 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 271 | CODE_SIGN_ENTITLEMENTS = CraneDemo/CraneDemo.entitlements; 272 | CODE_SIGN_STYLE = Automatic; 273 | CURRENT_PROJECT_VERSION = 1; 274 | DEVELOPMENT_TEAM = 8YF596VCP4; 275 | ENABLE_PREVIEWS = YES; 276 | GENERATE_INFOPLIST_FILE = YES; 277 | INFOPLIST_KEY_CFBundleDisplayName = Crane; 278 | INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Access the developer console"; 279 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 280 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 281 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 282 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 283 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 284 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 285 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 286 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 287 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 288 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 289 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 290 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 291 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 292 | MACOSX_DEPLOYMENT_TARGET = 15.0; 293 | MARKETING_VERSION = 1.0; 294 | PRODUCT_BUNDLE_IDENTIFIER = com.dockyard.crane; 295 | PRODUCT_NAME = "$(TARGET_NAME)"; 296 | REGISTER_APP_GROUPS = YES; 297 | SDKROOT = auto; 298 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 299 | SWIFT_EMIT_LOC_STRINGS = YES; 300 | SWIFT_VERSION = 5.0; 301 | TARGETED_DEVICE_FAMILY = "1,2,7"; 302 | XROS_DEPLOYMENT_TARGET = 2.4; 303 | }; 304 | name = Debug; 305 | }; 306 | 008F5FE42D80E05000C7171C /* Release */ = { 307 | isa = XCBuildConfiguration; 308 | buildSettings = { 309 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 310 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 311 | CODE_SIGN_ENTITLEMENTS = CraneDemo/CraneDemo.entitlements; 312 | CODE_SIGN_STYLE = Automatic; 313 | CURRENT_PROJECT_VERSION = 1; 314 | DEVELOPMENT_TEAM = 8YF596VCP4; 315 | ENABLE_PREVIEWS = YES; 316 | GENERATE_INFOPLIST_FILE = YES; 317 | INFOPLIST_KEY_CFBundleDisplayName = Crane; 318 | INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Access the developer console"; 319 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 320 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 321 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 322 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 323 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 324 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 325 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 326 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 327 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 328 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 329 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 330 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 331 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 332 | MACOSX_DEPLOYMENT_TARGET = 15.0; 333 | MARKETING_VERSION = 1.0; 334 | PRODUCT_BUNDLE_IDENTIFIER = com.dockyard.crane; 335 | PRODUCT_NAME = "$(TARGET_NAME)"; 336 | REGISTER_APP_GROUPS = YES; 337 | SDKROOT = auto; 338 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 339 | SWIFT_EMIT_LOC_STRINGS = YES; 340 | SWIFT_VERSION = 5.0; 341 | TARGETED_DEVICE_FAMILY = "1,2,7"; 342 | XROS_DEPLOYMENT_TARGET = 2.4; 343 | }; 344 | name = Release; 345 | }; 346 | /* End XCBuildConfiguration section */ 347 | 348 | /* Begin XCConfigurationList section */ 349 | 008F5FD12D80E04D00C7171C /* Build configuration list for PBXProject "CraneDemo" */ = { 350 | isa = XCConfigurationList; 351 | buildConfigurations = ( 352 | 008F5FE02D80E05000C7171C /* Debug */, 353 | 008F5FE12D80E05000C7171C /* Release */, 354 | ); 355 | defaultConfigurationIsVisible = 0; 356 | defaultConfigurationName = Release; 357 | }; 358 | 008F5FE22D80E05000C7171C /* Build configuration list for PBXNativeTarget "CraneDemo" */ = { 359 | isa = XCConfigurationList; 360 | buildConfigurations = ( 361 | 008F5FE32D80E05000C7171C /* Debug */, 362 | 008F5FE42D80E05000C7171C /* Release */, 363 | ); 364 | defaultConfigurationIsVisible = 0; 365 | defaultConfigurationName = Release; 366 | }; 367 | /* End XCConfigurationList section */ 368 | 369 | /* Begin XCLocalSwiftPackageReference section */ 370 | 007A39C92D8C645400597DB4 /* XCLocalSwiftPackageReference "../ElixirKitCrane" */ = { 371 | isa = XCLocalSwiftPackageReference; 372 | relativePath = ../ElixirKitCrane; 373 | }; 374 | 008382EC2D9C7F58001D4CF4 /* XCLocalSwiftPackageReference "../../Packages/liveview-client-swiftui" */ = { 375 | isa = XCLocalSwiftPackageReference; 376 | relativePath = "../../Packages/liveview-client-swiftui"; 377 | }; 378 | /* End XCLocalSwiftPackageReference section */ 379 | 380 | /* Begin XCSwiftPackageProductDependency section */ 381 | 008382ED2D9C7F58001D4CF4 /* LiveViewNative */ = { 382 | isa = XCSwiftPackageProductDependency; 383 | productName = LiveViewNative; 384 | }; 385 | 008F5FE62D80E0A800C7171C /* LiveViewNative */ = { 386 | isa = XCSwiftPackageProductDependency; 387 | productName = LiveViewNative; 388 | }; 389 | 00AC344D2D9491E8009E232F /* ElixirKitCrane */ = { 390 | isa = XCSwiftPackageProductDependency; 391 | package = 007A39C92D8C645400597DB4 /* XCLocalSwiftPackageReference "../ElixirKitCrane" */; 392 | productName = ElixirKitCrane; 393 | }; 394 | /* End XCSwiftPackageProductDependency section */ 395 | }; 396 | rootObject = 008F5FCE2D80E04D00C7171C /* Project object */; 397 | } 398 | -------------------------------------------------------------------------------- /CraneDemo/CraneDemo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CraneDemo 4 | // 5 | // Created by Carson.Katri on 3/11/25. 6 | // 7 | 8 | import SwiftUI 9 | import LiveViewNative 10 | import Crane 11 | 12 | // https://my-app-crimson-water-2591.fly.dev/ 13 | 14 | struct ContentView: View { 15 | @State private var crane = Crane() 16 | 17 | @State private var urlText = "" 18 | @State private var chromeVisible = true 19 | 20 | @State private var selectedTab: String? 21 | 22 | @AppStorage("favorites") private var favorites = Favorites(value: []) 23 | 24 | struct Favorites: RawRepresentable, Codable { 25 | let value: Set 26 | 27 | init(value: Set) { 28 | self.value = value 29 | } 30 | 31 | init?(rawValue: String) { 32 | guard let result = try? JSONDecoder().decode(Set.self, from: Data(rawValue.utf8)) 33 | else { return nil } 34 | self.value = result 35 | } 36 | 37 | var rawValue: String { 38 | guard let data = try? JSONEncoder().encode(self.value), 39 | let value = String(data: data, encoding: .utf8) 40 | else { return "[]" } 41 | return value 42 | } 43 | } 44 | 45 | struct StylesheetLoader: View { 46 | let url: URL 47 | @ViewBuilder let content: (Stylesheet?) -> Content 48 | 49 | @State private var stylesheet: Stylesheet? 50 | 51 | var body: some View { 52 | VStack { 53 | if let stylesheet { 54 | content(stylesheet) 55 | .transition(.opacity) 56 | } else { 57 | ProgressView("Stylesheet") 58 | .transition(.opacity) 59 | } 60 | } 61 | .task(id: url) { 62 | do { 63 | let (stylesheetData, _) = try await URLSession.shared.data(from: url) 64 | withAnimation(.default.speed(2)) { 65 | self.stylesheet = try? Stylesheet.init(from: String(data: stylesheetData, encoding: .utf8)!) 66 | } 67 | } catch { 68 | print(error) 69 | } 70 | } 71 | } 72 | } 73 | 74 | struct WindowView: View { 75 | var window: Crane.Window 76 | let navigate: (URL) -> () 77 | 78 | var body: some View { 79 | let _ = print("\(window.url)") 80 | ZStack { 81 | if let stylesheetURL = window.stylesheets.first.flatMap({ URL(string: $0, relativeTo: window.url) }) { 82 | StylesheetLoader(url: stylesheetURL) { stylesheet in 83 | ForEach(window.history.stack.indices, id: \.self) { index in 84 | if window.history.index == Int32(index) { 85 | DocumentView( 86 | url: window.url, 87 | document: window.documents[Int32(index)]!, 88 | stylesheet: stylesheet 89 | ) 90 | .transition(.opacity) 91 | } 92 | } 93 | } 94 | .animation(.default.speed(2), value: window.history.index) 95 | } 96 | } 97 | .environment(\.navigationHandler, { url in 98 | navigate(url) 99 | }) 100 | .animation(.default, value: window.history.index) 101 | } 102 | } 103 | 104 | struct WindowLabel: View { 105 | let window: Crane.Window 106 | @State private var urlText: String 107 | 108 | @Environment(Crane.self) private var crane 109 | 110 | init(window: Crane.Window) { 111 | self.window = window 112 | self._urlText = .init(wrappedValue: window.url.absoluteString) 113 | } 114 | 115 | var body: some View { 116 | HStack { 117 | TextField("Enter URL", text: $urlText) 118 | .autocorrectionDisabled() 119 | .textInputAutocapitalization(.never) 120 | .keyboardType(.URL) 121 | .multilineTextAlignment(.center) 122 | .onSubmit { 123 | if let url = URL(string: urlText) { 124 | Task { 125 | try await crane.navigate(window: window, to: url) 126 | } 127 | } 128 | } 129 | Button { 130 | Task { 131 | try await crane.refresh(window: window) 132 | } 133 | } label: { 134 | Image(systemName: "arrow.clockwise") 135 | } 136 | } 137 | } 138 | } 139 | 140 | struct WindowControls: View { 141 | let window: Crane.Window 142 | 143 | @Environment(Crane.self) private var crane 144 | 145 | var body: some View { 146 | // Back button 147 | Menu { 148 | ForEach(Array(window.history.stack.enumerated()), id: \.offset) { entry in 149 | Button { 150 | // navigate to index 151 | Task { 152 | try! await crane.back(window: window) 153 | } 154 | } label: { 155 | Text(entry.element.url) 156 | } 157 | .disabled(Int32(entry.offset) == window.history.index) 158 | } 159 | } label: { 160 | Image(systemName: "chevron.left") 161 | } primaryAction: { 162 | Task { 163 | try! await crane.back(window: window) 164 | } 165 | } 166 | .disabled(!window.canGoBack) 167 | // Forward button 168 | Button { 169 | Task { 170 | try! await crane.forward(window: window) 171 | } 172 | } label: { 173 | Image(systemName: "chevron.right") 174 | } 175 | .disabled(!window.canGoForward) 176 | } 177 | } 178 | 179 | var body: some View { 180 | return BrowserTabsView(selectedTab: $selectedTab, chromeVisible: chromeVisible) { 181 | ForEach(crane.windows, id: \.window.name) { window in 182 | BrowserTab(value: window.window.name) { 183 | VStack { 184 | // ForEach(Array(window.history.stack.enumerated()), id: \.offset) { entry in 185 | // if entry.offset == window.history.index { 186 | // Text("\(entry.element.url) (Active)") 187 | // } else { 188 | // Text(entry.element.url) 189 | // } 190 | // } 191 | WindowView(window: window, navigate: { url in 192 | Task { 193 | try! await crane.navigate(window: window, to: url) 194 | } 195 | }) 196 | } 197 | .simultaneousGesture( 198 | DragGesture() 199 | .onChanged { value in 200 | let deltaY = value.translation.height 201 | let scrollingDown = deltaY < 0 202 | 203 | if abs(deltaY) > 20 { 204 | withAnimation { 205 | chromeVisible = !scrollingDown 206 | } 207 | } 208 | } 209 | ) 210 | } label: { 211 | if chromeVisible { 212 | WindowLabel(window: window) 213 | } else { 214 | Button { 215 | withAnimation { 216 | chromeVisible = true 217 | } 218 | } label: { 219 | Text(window.url.absoluteString) 220 | .font(.caption) 221 | .padding(4) 222 | .frame(maxWidth: .infinity) 223 | } 224 | .tint(Color.primary) 225 | .padding(8) 226 | } 227 | } 228 | } 229 | } newTabForm: { 230 | TextField("Enter URL", text: $urlText) 231 | .autocorrectionDisabled() 232 | .textInputAutocapitalization(.never) 233 | .keyboardType(.URL) 234 | .multilineTextAlignment(.center) 235 | .onSubmit { 236 | if let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)) { 237 | Task { 238 | let window = try! await crane.newWindow(url: url) 239 | selectedTab = window.window.name 240 | urlText = "" 241 | } 242 | } 243 | } 244 | } newTabView: { 245 | NavigationStack { 246 | if favorites.value.isEmpty { 247 | ContentUnavailableView("New Tab", systemImage: "plus.square.fill.on.square.fill") 248 | .containerRelativeFrame(.horizontal) 249 | } else { 250 | List { 251 | Section("Favorites") { 252 | ForEach(favorites.value.sorted(by: { $0.absoluteString < $1.absoluteString }), id: \.absoluteString) { favorite in 253 | Button(favorite.absoluteString) { 254 | Task { 255 | let window = try! await crane.newWindow(url: favorite) 256 | selectedTab = window.window.name 257 | urlText = "" 258 | } 259 | } 260 | } 261 | } 262 | } 263 | .listStyle(.plain) 264 | .navigationTitle("New Tab") 265 | } 266 | } 267 | .containerRelativeFrame(.horizontal) 268 | } tabActions: { 269 | Button(role: .destructive) { 270 | Task { 271 | guard let window = crane.windows.first(where: { $0.window.name == selectedTab }) 272 | else { return } 273 | try await crane.close(window: window) 274 | } 275 | } label: { 276 | Label("Close", systemImage: "xmark") 277 | } 278 | } controls: { 279 | let window = crane.windows.first(where: { $0.window.name == selectedTab }) 280 | if let window { 281 | WindowControls(window: window) 282 | ShareLink(item: window.url) { 283 | Image(systemName: "square.and.arrow.up") 284 | } 285 | Button { 286 | var value = favorites.value 287 | if favorites.value.contains(window.url) { 288 | value.remove(window.url) 289 | } else { 290 | value.insert(window.url) 291 | } 292 | favorites = .init(value: value) 293 | } label: { 294 | if favorites.value.contains(window.url) { 295 | Image(systemName: "star.fill") 296 | } else { 297 | Image(systemName: "star") 298 | } 299 | } 300 | } else { 301 | Button {} label: { 302 | Image(systemName: "chevron.left") 303 | } 304 | .disabled(true) 305 | Button {} label: { 306 | Image(systemName: "chevron.right") 307 | } 308 | .disabled(true) 309 | Button {} label: { 310 | Image(systemName: "square.and.arrow.up") 311 | } 312 | .disabled(true) 313 | Button {} label: { 314 | Image(systemName: "star") 315 | } 316 | .disabled(true) 317 | } 318 | } 319 | .environment(crane) 320 | .task { 321 | // let window = try! await crane.newWindow(url: URL(string: "http://localhost:4000")!) 322 | // self.selectedTab = window.window.name 323 | } 324 | ZStack(alignment: .top) { 325 | // Content Area 326 | if let window = crane.windows.first { 327 | WindowView(window: window, navigate: { url in 328 | Task { 329 | try! await crane.navigate(window: window, to: url) 330 | } 331 | }) 332 | .simultaneousGesture( 333 | DragGesture() 334 | .onChanged { value in 335 | let deltaY = value.translation.height 336 | let scrollingDown = deltaY < 0 337 | 338 | if abs(deltaY) > 20 { 339 | withAnimation { 340 | chromeVisible = !scrollingDown 341 | } 342 | } 343 | } 344 | ) 345 | } 346 | 347 | // Fixed Chrome UI 348 | VStack { 349 | // Top Chrome Area 350 | VStack { 351 | HStack(spacing: 12) { 352 | let window = crane.windows.first 353 | 354 | 355 | Button { 356 | Task { 357 | if let window { 358 | try! await crane.back(window: window) 359 | } 360 | } 361 | } label: { 362 | Image(systemName: "chevron.backward") 363 | .font(.system(size: 16, weight: .medium)) 364 | } 365 | .disabled(window?.canGoBack ?? true) 366 | 367 | // Forward button 368 | Button { 369 | Task { 370 | if let window = crane.windows.first { 371 | try! await crane.forward(window: window) 372 | } 373 | } 374 | } label: { 375 | Image(systemName: "chevron.forward") 376 | .font(.system(size: 16, weight: .medium)) 377 | } 378 | .disabled(window?.canGoForward ?? true) 379 | 380 | // URL TextField 381 | TextField("Enter URL", text: $urlText) 382 | .textFieldStyle(.roundedBorder) 383 | .autocorrectionDisabled() 384 | .textInputAutocapitalization(.never) 385 | .keyboardType(.URL) 386 | .tint(.blue) 387 | .onSubmit { 388 | if let url = URL(string: urlText) { 389 | Task { 390 | if let window = crane.windows.first { 391 | try await crane.navigate(window: window, to: url) 392 | } else { 393 | try await crane.newWindow(url: url) 394 | } 395 | } 396 | } 397 | } 398 | 399 | // Reload button 400 | Button { 401 | Task { 402 | if let window = crane.windows.first { 403 | try! await crane.refresh(window: window) 404 | } 405 | } 406 | } label: { 407 | Image(systemName: "arrow.clockwise") 408 | .font(.system(size: 16, weight: .medium)) 409 | .foregroundColor(.blue) 410 | } 411 | } 412 | .padding(.horizontal) 413 | .padding(.vertical, 8) 414 | .background(.ultraThinMaterial) 415 | } 416 | .offset(y: chromeVisible ? 0 : -150) 417 | .animation(.spring(response: 0.3, dampingFraction: 0.8), value: chromeVisible) 418 | 419 | Spacer() 420 | 421 | // Bottom toolbar area with settings button 422 | // HStack { 423 | // Spacer() 424 | // Button { 425 | // isFavorite.toggle() 426 | // } label: { 427 | // Image(systemName: isFavorite ? "star.fill" : "star") 428 | // .font(.system(size: 20)) 429 | // .foregroundColor(.blue) 430 | // .padding(.trailing, 8) 431 | // } 432 | // Link(destination: URL(string: "https://dockyard.com")!) { 433 | // Image(systemName: "seal.fill") 434 | // .font(.system(size: 20)) 435 | // .foregroundColor(.blue) 436 | // .padding(.trailing, 8) 437 | // } 438 | // Button { 439 | // showSettings = true 440 | // } label: { 441 | // Image(systemName: "gear") 442 | // .font(.system(size: 20)) 443 | // .foregroundColor(.blue) 444 | // .padding(.trailing, 16) 445 | // } 446 | // } 447 | // .frame(height: 44) 448 | // .background(.ultraThinMaterial) 449 | // .offset(y: chromeVisible ? 0 : 100) 450 | // .animation(.spring(response: 0.3, dampingFraction: 0.8), value: chromeVisible) 451 | } 452 | } 453 | } 454 | } 455 | 456 | #Preview { 457 | ContentView() 458 | } 459 | -------------------------------------------------------------------------------- /lib/crane/phoenix/layout.ex: -------------------------------------------------------------------------------- 1 | defmodule Crane.Phoenix.Layout do 2 | use Phoenix.Component 3 | 4 | def console(assigns) do 5 | ~H""" 6 | 7 | <%= if Application.get_env(:live_debugger, :browser_features?) do %> 8 |